diff --git a/.gitignore b/.gitignore index 5ce9c8b..6651622 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .pytest_cache/ booth-data/ uv.lock +graphify-out/ diff --git a/CLAUDE.md b/CLAUDE.md index 7a72898..409541b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,31 +35,35 @@ When you commit, include any pending `persistent-memory.md` and floating uncommitted change while shipping other work — durable memory that lags the code defeats its own purpose. -## The five invariants +## The six invariants These are the ones a casual change breaks silently. Each has a test. -### 1. `links.py` and `asks.py` are stdlib-only, on purpose +### 1. `links.py`, `asks.py` and `marks.py` are stdlib-only, on purpose `scripts/booth` — the CLI every fleet session uses — imports them directly: ```sh -BOOTH_SRC=… python3 -c 'import sys; sys.path.insert(0, …); from booth.asks import write_ask' +BOOTH_SRC=… python3 -c 'import sys; sys.path.insert(0, …); from booth.marks import declare_pick' ``` It runs under the system `python3` with **no venv**. A single third-party -import in either module breaks `booth ask` / `booth answer` / `booth unlink` -on every host, and the failure surfaces in an agent's session, not in ours. +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. -`items.py` and `app.py` are free to import what they like. Those two are not. +`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. ### 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), `.pins` (link-board pin ids), `.uploaded` -(upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new +`.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 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. @@ -91,12 +95,39 @@ would be found by a consumer, not by us. ### 5. Sidecar writes are atomic; text bodies stay raw Anything a session may read while the browser writes it goes through temp file -+ `os.replace` (see `asks.write_answer`). A reader never sees a partial file. ++ `os.replace` (see `marks._write_raw`). A reader never sees a partial file, and +a crash mid-write cannot truncate a file into a shorter — and therefore quieter +— set of marks or a more revealing blur set. `render_doc` returns **raw** text for the non-markdown case on purpose: the template escapes it inside `
`, and pre-escaping here double-encodes under
 Jinja autoescape.
 
+### 6. Every ordered collection has a stated, deterministic order
+
+Operator directive, 2026-09-21. Not "usually stable" and not "whatever `rglob`
+yields" — a rule you can write down in one line, producing the same sequence on
+every render of the same state. Any defensible rule qualifies: byte order over a
+path, creation time, an explicit number, an arbitrary-but-recorded sequence. No
+rule at all does not.
+
+The Booth's job is comparison, which makes this load-bearing rather than tidy.
+The operator judges tile 47 of `pancake-v3-full` against tile 47 of
+`pancake-v4-full`, and refers to artifacts positionally — "the third one", "the
+one after the banded one". If the order moves between renders, or differs
+between the gallery, the zoom ring, the zip and the `marks` read, a flag or a
+note lands on the wrong artifact. It never shows up as a crash; it shows up as
+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).
+
+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.
+
 ## Multi-writer vs single-writer — don't inherit the wrong shape
 
 `links.md` is a **multi-writer** append log: 17 agent handles post to it
diff --git a/README.md b/README.md
index 4a9210f..5333f27 100644
--- a/README.md
+++ b/README.md
@@ -189,155 +189,89 @@ Deliberately **not** a database. The board is a markdown file — editable with
 any editor, greppable, and trivially prunable by hand, which is the whole point
 of the Booth's filesystem-is-the-state model.
 
-## Asks — let the operator pick one of N, and read the pick back
+## Marks — operator judgment, attached to an artifact
 
-The one **interactive** primitive. A session needs a human decision — which
-render wins, which plan, go/no-go — and wants to act on it without a chat
-round-trip. Drop a question in a booth; the page renders it as a radio form
-with a notes field; the operator's submit writes an **answer sidecar** the
-session reads. Filesystem is still the state:
+The one **interactive** primitive, and one primitive for what used to be three
+jobs:
+
+| shape | who writes it | what it is |
+|---|---|---|
+| **`pick`** | a session declares the options, the operator chooses | "which render wins?" — this is what `booth ask` poses |
+| **`note`** | the operator, in the browser | free text for the session that posted the work |
+| **`flag`** | the operator, in the browser | *this one* — selecting winners out of a set |
+
+All three are the operator judging something and the session reading the
+judgment back. They live in **one file per booth**, so "does this booth still owe
+me an answer?" is a single read:
 
 ```
-/.ask.json      the question   (a session writes it)
-/.answer.json   the answer     (the web UI writes it, atomically)
+/.marks.json    every mark in the booth   (dotfile: never a tile, never in the zip)
+/.marks.lock    the write lock            (a session declares, the operator answers)
 ```
 
+There are **no `note` or `flag` CLI verbs**, on purpose: the CLI is the session's
+side of the loop, and a session does not author the operator's judgment. It reads
+it.
+
 ```bash
 # On nh3-dev — pose, then block until answered (default 1h), then act on it:
 booth ask r18-ab winner "Which render wins?" "A — baseline" "B — cudaMallocAsync"
 booth answer r18-ab winner --wait            # prints the answer JSON when it lands
 booth answer r18-ab winner                   # non-blocking: exit 1 while unanswered
-booth asks   r18-ab                          # list a booth's asks + state
+booth marks  r18-ab                          # every mark in the booth, as JSON
+booth marks  r18-ab --wait                   # block while any pick is still open
 
-# Options can carry an id + detail line instead of a bare label — write the
-# JSON yourself (booth.asks.write_ask validates the same way):
-cat > ~/booth-data/r18-ab/plan.ask.json <<'EOF'
-{"title": "optional short label above the question",
- "prompt": "Ship which?",
- "options": [{"id": "a", "label": "Plan A", "detail": "smaller diff, no migration"},
-             {"id": "b", "label": "Plan B", "detail": "cleaner, needs the DB change"}],
- "notes": true, "notes_label": "why / conditions"}
-EOF
+# Options can carry an id + detail line instead of a bare label. Write the whole
+# declaration yourself and it is validated by the same normaliser the page uses:
+booth ask r18-ab plan "Ship which?" "Plan A" "Plan B"    # or, for detail lines:
+python3 -c '
+import pathlib, sys; sys.path.insert(0, "/home/lkraven/development/booth")
+from booth.marks import declare_pick
+declare_pick(pathlib.Path("/home/lkraven/booth-data/r18-ab"), "plan", {
+  "title": "optional short label above the question",
+  "prompt": "Ship which?",
+  "options": [{"id": "a", "label": "Plan A", "detail": "smaller diff, no migration"},
+              {"id": "b", "label": "Plan B", "detail": "cleaner, needs the DB change"}],
+  "notes": True, "notes_label": "why / conditions"})'
 
-# From another host: rsync the ask in, then poll the sidecar over HTTP:
-curl -sf http://10.100.10.50:8090/b/r18-ab/winner.answer.json   # 404 until answered
+# From another host: rsync your work in, then read the judgment over HTTP.
+# ONE request for the whole booth, rather than one per question.
+curl -sf http://10.100.10.50:8090/b/r18-ab/marks.json | jq '.open, .marks[].answer'
 ```
 
-**Several questions, one form.** Give the ask a `questions` list instead of
-`prompt`+`options`; the page renders one form with a radio group per question
-and a single submit, every question required. Per-question `notes: true` adds
-a small text field under that question; the form-level `notes` stays one field
-for the whole ask. The answer is keyed by question:
+**Several questions, one form.** Give the declaration a `questions` list instead
+of `prompt`+`options`; the page renders one form with a radio group per question
+and a single submit. Per-question `notes: true` adds a small text field under
+that question; the form-level `notes` stays one field for the whole pick. The
+answer is keyed by question:
+
+```python
+declare_pick(booth, "batch", {
+  "title": "R18 batch review",
+  "questions": [
+    {"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": True},
+    {"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]},
+    {"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}],
+  "notes": True, "notes_label": "anything else"})
+# -> answer: {"stem", "title", "answers": {"r1": {"prompt", "choice",
+#    "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}},
+#    "unanswered", "complete", "notes", "answered_at", "answered_by"}
+```
+
+**A partial answer is recorded, not refused.** A question left blank is a
+deliberate outcome — "none of these", "not yet", "ask me later" — so it lands in
+`unanswered`, stays absent from `answers` unless it carried a note, and
+`complete` stays false. **A partially-answered pick still counts as open**, which
+is what the index badge reports. The one refusal is a submission carrying nothing
+at all: no choice anywhere and no notes.
+
+**Migrating a booth that predates marks.** The old two-sidecars-per-question
+files (`.ask.json` / `.answer.json`) are imported, never deleted:
 
 ```bash
-cat > ~/booth-data/r18-ab/batch.ask.json <<'EOF'
-{"title": "R18 batch review",
- "questions": [
-   {"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": true},
-   {"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]},
-   {"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}],
- "notes": true, "notes_label": "anything else"}
-EOF
-# -> batch.answer.json: {"stem", "title", "answers": {"r1": {"prompt", "choice",
-#    "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}}, "notes", "answered_at", "answered_by"}
+booth marks-import r18-ab      # idempotent; the sidecars stay on disk
 ```
 
-Both shapes also carry **`unanswered`** (the question keys left blank; `[null]`
-for a blank single-question ask) and **`complete`** (false until every question
-has a pick). A reading session should check `complete` before acting on a
-multi-question answer, and treat a key in `unanswered` as "not decided", never
-as "declined".
-
-The single-question answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes",
-"unanswered", "complete", "answered_at", "answered_by"}` — `choice` is the option id (the label itself
-for string options), `choice_index` its 0-based position, `answered_by` the
-client address. `POST /b//answer` is what the form submits — fields `ask` plus
-`choice` / `notes` (single) or `choice.` / `notes.` / `notes` (multi);
-a missing or bad choice is a 400, an unknown stem a 404.
-
-Rules of the primitive:
-
-- **Radio, one pick per question.** ≥ 2 options, ≤ 40 per question, ≤ 30
-  questions per ask. No multi-select checkboxes (not yet asked for). Many asks
-  per booth are fine — each is its own form and its own sidecar; use
-  `questions` when the picks belong together and should land as one answer.
-- **Re-answering overwrites.** The sidecar is the *current* answer, not a log.
-  The page shows the recorded answer with a collapsed *change answer* form.
-- **Blanks are legal — a partial answer is recorded, not refused.** Leaving a
-  question alone is a real outcome ("none of these", "not listened to yet"), and
-  refusing the whole submission over one blank threw away the picks that WERE
-  made. So every answered question is recorded, every blank one lands in
-  `unanswered`, and `complete` says whether the set is finished. The radios carry
-  no HTML `required`, so the browser does not block the submit either. A question
-  left blank but carrying a note keeps the note (`choice: null`). The one refusal
-  is a submission with **no pick anywhere and no notes** — a 400, because it would
-  flip an open ask to "answered" while recording no decision, which is worse for
-  the reading session than leaving it open. A choice that is not in the option
-  list is still an error: that is a broken form, not a skipped question.
-  Partially-answered asks show as `◐ partial` with an `n/N` count; re-submitting
-  fills in the rest.
-- **Open asks are flagged** — an amber `? N asks` badge on the index card and in
-  the booth header — so a waiting question is visible from the front page.
-- **A broken ask is shown as broken**, not hidden: if the JSON does not
-  validate, the page says why, so a session never thinks it posted a question
-  the operator cannot see.
-- Ask/answer files are not gallery items and do not count toward the booth's
-  item count; they render as the panel above the gallery. Answering bumps the
-  booth's mtime, so it lives another TTL — the session has 24h to read it.
-- Works with JavaScript off (plain form POST). No auth, same as everything here.
-
-### Where the form renders
-
-Two booth shapes, two placements. Either way the ask is never invisible — that
-is the guarantee; markup only moves it somewhere better.
-
-**Auto-gallery booth** (no `index.html` of its own): the asks panel renders
-above the gallery, styled like the rest of the Booth. Nothing to do.
-
-**A booth serving its own `index.html`**: that page is returned verbatim, so the
-Booth substitutes **placeholders in your markup** rather than rendering a panel
-above a gallery that does not exist. The question then sits with the artifact it
-is about (operator ruling 2026-09-09: *"the asks should be inline with the
-artifacts, not on a separate page"*).
-
-**When inline is worth the markup, and when it is not.** The test is whether the
-artifact can be held in the head while the form is on screen. Two short images
-side by side — no, the appended form is fine. Twenty audio clips, five per voice
-across four voices — yes: on a separate page the operator is choosing from
-*memory of the audio*, not from the audio, and by the fourth voice that memory is
-gone. That is the case this mechanism exists for (framing owed to tts-dev,
-2026-09-09, from the `redo-anchors` audition).
-
-```html
-
-
-
- -``` - -Per-question fragments bind to **one** form via the HTML5 `form=` attribute, so a -four-voice audition puts each radio group under that voice's audio and still -submits every pick in a single POST — which is what a multi-question ask -requires. Fragments ship their own scoped styles, inherit nothing from your page, -and use no JavaScript. - -⚠ **Put the placeholder outside any CSS grid or flex container**, or it becomes a -cell in it — measured on `redo-anchors`, where the first attempt rendered as a -224 px sixth grid cell wedged between two audio players. A sibling of the block -it belongs to is right. - -The fallbacks, so a page can never strand a question: - -| you marked up | what happens | -|---|---| -| nothing | the whole ask is appended at the end of the page | -| some questions, no submit | the rest of the questions **and** a submit block are appended | -| a stem this booth does not have | your markup is left alone, untouched; the real ask is still appended | - -An amber `? N open asks` chip floats top-right as a jump link to the first open -ask, and `GET /b//asks` still renders every ask on a plain page of its own -— useful when you want to hand someone only the question. - ## Upload for pickup The reverse direction — put files in through the web, pick them up by id: @@ -386,8 +320,13 @@ to a safe basename (no path traversal). | `GET /b//` | A booth (its `index.html`, else auto-gallery) | | `GET /b//` | Serve a file out of the booth | | `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b//` (id in `Location`) | -| `GET /b//asks` | The asks panel on its own page — the only place a verbatim-`index.html` booth can show its asks | -| `POST /b//answer` | Answer an ask (fields `ask` = stem, `choice`/`choice.`, `notes`/`notes.`, `back`); writes `.answer.json`, 303 back | +| `GET /b//marks` | The marks panel on its own page — the only place a verbatim-`index.html` booth can show its marks (`/asks` 308s here) | +| `GET /b//marks.json` | Every mark as JSON, plus `open` — the read path for a session that is not on this host | +| `POST /b//answer` | Answer a pick (fields `ask` = mark id, `choice`/`choice.`, `notes`/`notes.`, `back`); 303 back | +| `POST /b//note` | Attach free text to an item (`target`) or to the booth (`target` empty); `text` required | +| `POST /b//flag` | Flag or unflag one item (`target`, `on`) — an upsert; unflagging removes the mark | +| `POST /b//unmark` | Withdraw one mark (`mark` = its id) | +| `POST /b//import-asks` | Import this booth's legacy `*.ask.json` sidecars; idempotent, deletes nothing | | `POST /b//delete` | Wipe a booth (the UI's "Wipe now" button) | | `POST /b//keep` | Pin a booth — exempt from the sweep | | `POST /b//unkeep` | Release the pin (the UI's "release" button on kept cards) | diff --git a/ROADMAP.md b/ROADMAP.md index 5af8c51..3fd62cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,6 +22,43 @@ 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. +### Cross-cutting invariant — deterministic order, everywhere + +**Every ordered collection the Booth renders must have a stated, deterministic +order.** Not "usually stable", not "whatever the filesystem yields" — a rule +someone can name, that produces the same sequence on every render of the same +state. The rule itself is free to be anything defensible: byte order over a +path, creation time, an explicit number, even an arbitrary-but-recorded +sequence. What is forbidden is *no rule*. + +This matters more here than in most services because the Booth's whole job is +**comparison**. The operator is judging `pancake-v3-full` against +`pancake-v4-full`, tile 47 against tile 47. If the order shifts between two page +loads — or differs between the gallery, the zoom ring, the zip manifest and the +`marks` read — then every positional reference the operator makes ("the third +one from the left", "the one after the banded one") is silently wrong, and a +flag or a note lands on the wrong artifact. Non-determinism does not present as +a bug report; it presents as the operator's judgment being quietly misfiled. + +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 | +| 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 | + +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**. + +The test for any new ordered surface: *can you write the rule down in one line?* +If not, it does not have one yet. + ### Explicitly NOT in v1 - **Backward compatibility with the `ask` CLI verbs.** Pre-1.0, and `ask` / diff --git a/booth/app.py b/booth/app.py index de7be88..592c58f 100644 --- a/booth/app.py +++ b/booth/app.py @@ -118,10 +118,20 @@ from booth.asks import ( # noqa: E402 AskError, is_answer_file, is_ask_file, - list_asks, - load_ask, valid_stem, - write_answer, +) +from booth.marks import ( # noqa: E402 + MARKS_FILE, + answer_pick, + as_dict, + declare_pick, + delete_mark, + import_legacy_asks, + marks_for, + marks_for_target, + open_marks, + set_flag, + write_note, ) from booth.inline import ( # noqa: E402 form_id as ask_form_id, @@ -240,9 +250,11 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> if not child.is_dir() or child.name.startswith("."): continue items = booth_items(child) - # Asks are questions, not items: counted separately so the index can - # flag a booth that is waiting on the operator. - asks = list_asks(child) + # Marks are judgment, not items: counted separately so the index can + # flag a booth that is waiting on the operator. ONE file read per booth + # — which is why marks live in one file per booth rather than a sidecar + # per mark. This loop runs on every index page load. + marks = marks_for(child) kinds = {"image": 0, "video": 0, "audio": 0, "other": 0} thumb_url = None thumb_blurred = False @@ -267,8 +279,11 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> "has_index": (child / "index.html").is_file(), "uploaded": (child / UPLOAD_MARKER).exists(), "kept": is_kept(child), - "asks_total": len(asks), - "asks_open": sum(1 for a in asks if a["answer"] is None and not a["error"]), + "marks_total": len(marks), + # `open_marks` and nothing else (INV-2). The count this replaced + # tested `answer is None`, so a half-answered pick read as closed + # here while the panel beside it rendered `◐ partial`. + "marks_open": len(open_marks(marks)), "expires_in": max(0.0, ttl_seconds - (now - mtime)), "mtime": mtime, } @@ -610,6 +625,14 @@ def create_app( except OSError: pass return FileResponse(str(own_index), media_type="text/html") + # links.md is rendered AS the board below, so it must not also appear as + # a markdown doc tile — that would show the same content twice, once + # interactive and once not. + gallery = [ + it for it in build_gallery(booth) + if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE) + ] + marks = marks_for(booth) return templates.TemplateResponse( request, "booth.html", @@ -620,13 +643,7 @@ def create_app( # The page could not previously tell keep from release, so it # offered neither and you had to go back to the index. "kept": is_kept(booth), - # links.md is rendered AS the board below, so it must not also - # appear as a markdown doc tile — that would show the same - # content twice, once interactive and once not. - "items": [ - it for it in build_gallery(booth) - if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE) - ], + "items": gallery, # A booth carrying links.md is the standing link board: render # its rows as real UI (link, provenance, pin, per-row + bulk # remove) instead of a markdown blob you can only edit by hand. @@ -640,48 +657,135 @@ def create_app( ) if (booth / LINKS_FILE).is_file() else [] ), - # Asks: multiple-choice questions a session left for the - # operator, rendered as forms above the gallery (open ones) - # or as their recorded answer. See booth/asks.py. - "asks": list_asks(booth), + # Marks: operator judgment attached to this booth or to one of + # its items — a session's question (`pick`), the operator's own + # remark (`note`), the operator's selection (`flag`). Rendered + # as the panel above the gallery, and per item on each tile. + # See booth/marks.py. + "marks": marks, + "marks_open": len(open_marks(marks)), + # Per-item marks, keyed by rel, so a tile reads its own judgment + # without every tile re-filtering the whole list. + "item_marks": { + it["name"]: marks_for_target(marks, it["name"]) for it in gallery + }, + "booth_marks": marks_for_target(marks, None), "uploaded": (booth / UPLOAD_MARKER).exists(), "expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)), }, ) + def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse: + """Land where the form was: the standalone marks page for a verbatim + booth (its own index.html cannot show the recorded judgment), else the + booth page, scrolled to the mark that was just written.""" + base = f"/b/{quote(name, safe='')}/" + if form.get("back") == "marks": + base = f"/b/{quote(name, safe='')}/marks" + return RedirectResponse(url=f"{base}#{anchor}", status_code=303) + @app.post("/b/{name}/answer") async def booth_answer(request: Request, name: str): - """Record the operator's answer to one ask: validates every choice - against the ask and writes `.answer.json` atomically. - Re-submitting overwrites — the sidecar is the current answer. + """Record the operator's pick — one of N options a session declared in + advance. Validates every choice against the declaration and rewrites + `.marks.json` atomically. Re-submitting overwrites: the mark is the + CURRENT judgment, not a log. - Form fields: `ask` (stem); single-question → `choice` + `notes`; + Form fields: `ask` (the mark id); single-question → `choice` + `notes`; multi-question → `choice.` per question, optional `notes.`, - plus the form-level `notes`. 404 for an unknown/invalid stem, 400 for - a missing choice or one the ask does not offer. + plus the form-level `notes`. 404 for an unknown id, 400 for a missing + choice or one the declaration does not offer. + + Kept at `/answer` with an `ask` field rather than renamed: the inline + fragments a report author has already marked up POST here, and breaking + every landed verbatim report to tidy a URL is not a trade worth making. """ booth = resolve_booth(name) form = await request.form() - ask = form.get("ask") - if not isinstance(ask, str) or not valid_stem(ask) or not (booth / f"{ask}{ASK_SUFFIX}").is_file(): - raise HTTPException(status_code=404, detail="no such ask") + mark_id = form.get("ask") + if not isinstance(mark_id, str) or not valid_stem(mark_id): + raise HTTPException(status_code=404, detail="no such pick") + spec = next((m for m in marks_for(booth) if m.id == mark_id and m.shape == "pick"), None) + if spec is None: + raise HTTPException(status_code=404, detail="no such pick") + if spec.error is not None: + raise HTTPException(status_code=400, detail=spec.error) who = request.client.host if request.client else "" try: - spec = load_ask(booth, ask) - if spec["multi"]: - choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec["questions"]} - qnotes = {q["key"]: form.get(f"notes.{q['key']}") for q in spec["questions"]} - write_answer(booth, ask, choice, form.get("notes", ""), who=who, qnotes=qnotes) + if spec.multi: + choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec.questions} + qnotes = {q["key"]: form.get(f"notes.{q['key']}") for q in spec.questions} + answer_pick(booth, mark_id, choice, form.get("notes", ""), who=who, qnotes=qnotes) else: - write_answer(booth, ask, form.get("choice"), form.get("notes", ""), who=who) + answer_pick(booth, mark_id, form.get("choice"), form.get("notes", ""), who=who) except AskError as exc: raise HTTPException(status_code=400, detail=str(exc)) - # Land where the form was: the standalone /asks page for a verbatim booth - # (its own index.html cannot show the recorded answer), else the booth. - base = f"/b/{quote(name, safe='')}/" - if form.get("back") == "asks": - base = f"/b/{quote(name, safe='')}/asks" - return RedirectResponse(url=f"{base}#ask-{quote(ask, safe='')}", status_code=303) + return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}") + + @app.post("/b/{name}/note") + async def booth_note(request: Request, name: str): + """Attach free text to one item, or to the booth itself. + + The operator telling the session — a direction that had no mechanism at + all before marks, which is exactly why it was running through chat. + `target` empty or absent means the booth. 400 on empty text. + """ + booth = resolve_booth(name) + form = await request.form() + raw_target = form.get("target") + target = raw_target if isinstance(raw_target, str) and raw_target else None + text = form.get("text") + try: + mark = write_note(booth, target, text if isinstance(text, str) else "", + who=request.client.host if request.client else "") + except AskError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return _mark_redirect(name, form, f"mark-{quote(mark.id, safe='')}") + + @app.post("/b/{name}/flag") + async def booth_flag(request: Request, name: str): + """Flag or unflag one item — the operator pointing at the good ones. + + The shape that makes a 270-image booth tractable, and the one that + closes the loop `golden-candidates` / `sindra-finalists` / the + `pancake-*` ladders were running through conversation. + """ + booth = resolve_booth(name) + form = await request.form() + target = form.get("target") + if not isinstance(target, str) or not target: + raise HTTPException(status_code=400, detail="a flag needs a target") + on = str(form.get("on", "1")) not in ("0", "", "false", "off") + try: + set_flag(booth, target, on, who=request.client.host if request.client else "") + except AskError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return _mark_redirect(name, form, f"item-{quote(target, safe='')}") + + @app.post("/b/{name}/unmark") + async def booth_unmark(request: Request, name: str): + """Withdraw one mark — the operator's undo. Withdrawing a judgment is + his to do; nothing else here removes a mark.""" + booth = resolve_booth(name) + form = await request.form() + mark_id = form.get("mark") + if not isinstance(mark_id, str) or not mark_id: + raise HTTPException(status_code=400, detail="which mark?") + delete_mark(booth, mark_id) + return _mark_redirect(name, form, "marks") + + @app.post("/b/{name}/import-asks") + async def booth_import_asks(request: Request, name: str): + """Import this booth's legacy `*.ask.json` sidecars into `.marks.json`. + + Idempotent, and it deletes nothing — the sidecars stay on disk. Exposed + as a route as well as a CLI verb so a booth that predates marks can be + migrated from the page you are already looking at. + """ + booth = resolve_booth(name) + import_legacy_asks(booth) + form = await request.form() + return _mark_redirect(name, form, "marks") _frag = templates.env.get_template("_ask_inline.html").module @@ -695,77 +799,106 @@ def create_app( whose questions were placed but whose submit block was not gets that block appended, so a scattered form is always submittable. """ - asks = list_asks(booth) - if not asks: + picks = [m for m in marks_for(booth) if m.shape == "pick"] + if not picks: return html, "" url = quote(name, safe="") seen: set[str] = set() - def render(kind: str, ask: dict, key: str | None) -> str: - fid = ask_form_id(ask["stem"]) + def render(kind: str, mark, key: str | None) -> str: + fid = ask_form_id(mark.id) if kind == "whole": - frag = str(_frag.whole(ask, fid, url)) + frag = str(_frag.whole(mark, fid, url)) elif kind == "submit": - frag = str(_frag.submit(ask, fid, url)) + frag = str(_frag.submit(mark, fid, url)) else: - q = next(q for q in ask["questions"] if q.get("key") == key) - frag = str(_frag.question(ask, q, fid, url)) - # An anchor on the FIRST fragment of each stem, wherever it landed, + q = next(q for q in mark.questions if q.get("key") == key) + frag = str(_frag.question(mark, q, fid, url)) + # An anchor on the FIRST fragment of each pick, wherever it landed, # so the floating chip can jump to it on a long report. Computed # here rather than in the macros because only the caller knows # which fragment came first. - if ask["stem"] not in seen: - seen.add(ask["stem"]) - frag = f'' + frag + if mark.id not in seen: + seen.add(mark.id) + frag = f'' + frag return frag tail = [str(_frag.styles())] if has_placeholders(html): - html, placed, submitted = place_asks(html, asks, render) - for a in asks: - keys = placed.get(a["stem"]) + html, placed, submitted = place_asks(html, picks, render) + for m in picks: + keys = placed.get(m.id) if keys is None: - tail.append(render("whole", a, None)) # unmarked: never dropped + tail.append(render("whole", m, None)) # unmarked: never dropped continue - if a["error"]: + if m.error: continue if None not in keys: # Partially marked up: append every question the author did - # NOT place. A multi-question ask needs all of them or the + # NOT place. A multi-question pick needs all of them or the # POST is a 400 — met only after the operator fills it in. - for q in a["questions"]: + for q in m.questions: if q.get("key") not in keys: - tail.append(render("question", a, q.get("key"))) - if a["stem"] not in submitted: - tail.append(render("submit", a, None)) # scattered but submittable + tail.append(render("question", m, q.get("key"))) + if m.id not in submitted: + tail.append(render("submit", m, None)) # scattered but submittable else: - for a in asks: - tail.append(render("whole", a, None)) + for m in picks: + tail.append(render("whole", m, None)) - # The chip is now a JUMP LINK to the inline block, not a way out to a + # The chip is a JUMP LINK to the inline block, not a way out to a # separate 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. - first_open = next((a for a in asks if a["answer"] is None and not a["error"]), None) - open_n = sum(1 for a in asks if a["answer"] is None and not a["error"]) - if first_open is not None: - tail.append(asks_chip(name, open_n, href=f'#bk-ask-{first_open["stem"]}-top')) + still_open = open_marks(picks) # INV-2: not re-derived here + if still_open: + tail.append(asks_chip(name, len(still_open), + href=f'#bk-ask-{still_open[0].id}-top')) return html, "".join(tail) - @app.get("/b/{name}/asks", response_class=HTMLResponse) - def booth_asks_page(request: Request, name: str): - """The asks panel on its own page. Reachable from any booth, and the ONLY - place a verbatim-index.html booth can show its asks — that page is served + @app.get("/b/{name}/marks", response_class=HTMLResponse) + def booth_marks_page(request: Request, name: str): + """The marks panel on its own page. Reachable from any booth, and the ONLY + place a verbatim-index.html booth can show its marks — that page is served untouched by design, so the inline panel never renders there.""" booth = resolve_booth(name) + marks = marks_for(booth) return templates.TemplateResponse( request, - "asks.html", + "marks.html", {**base_ctx, "name": name, "name_url": quote(name, safe=""), - "asks": list_asks(booth), "asks_page": True}, + "marks": marks, "marks_open": len(open_marks(marks)), + "booth_marks": marks_for_target(marks, None), "marks_page": True}, ) + @app.get("/b/{name}/asks", include_in_schema=False) + def booth_asks_redirect(name: str): + """`/asks` moved to `/marks` when asks became one shape of mark. A + redirect rather than a 404: the URL is in the operator's history and in + landed reports, and a dead link teaches nothing.""" + return RedirectResponse(url=f"/b/{quote(name, safe='')}/marks", status_code=308) + + @app.get("/b/{name}/marks.json") + def booth_marks_json(name: str): + """Every mark in the booth, as JSON — the READ path for a session that + is not on this host. + + `booth marks` covers a session with filesystem access; a session on + another box rsyncs its work in and has only HTTP. Before marks it polled + `.answer.json` and waited for a 404 to become a 200, which is why + this endpoint has to exist: without it, moving picks out of per-question + sidecars would take that capability away. One request now answers for + the whole booth instead of one question at a time. + """ + booth = resolve_booth(name) + marks = marks_for(booth) + return JSONResponse({ + "booth": name, + "marks": [as_dict(m) for m in marks], + "open": [m.id for m in open_marks(marks)], + }) + @app.get("/b/{name}/view", response_class=HTMLResponse) def booth_view_file(request: Request, name: str, f: str): """Full-size view of ONE item — image zoom, or a doc as a readable page. @@ -786,6 +919,8 @@ def create_app( items = booth_items(booth) item = find_item(items, f) + marks = marks_for(booth) + item_marks = marks_for_target(marks, f) common = { **base_ctx, "name": name, @@ -796,6 +931,11 @@ def create_app( "caption": item.caption if item else None, "section": item.section if item else None, "blurred": item.blurred if item else False, + # INV-3, U1's rule extended from the caption to the judgment: the + # notes and the flag state travel to full size, which is the size at + # which the judgment is actually being made. + "marks": item_marks, + "flagged": any(m.shape == "flag" for m in item_marks), } if item is not None and item.kind == "image": diff --git a/booth/asks.py b/booth/asks.py index 3b631b5..083ed39 100644 --- a/booth/asks.py +++ b/booth/asks.py @@ -1,53 +1,65 @@ -"""Asks: a session poses a multiple-choice question in a booth; the operator -answers it in the browser; the answer lands as a sidecar the session reads. +"""Pick validation and answer shaping — the semantics, without the storage. -STDLIB ONLY, like links.py, so the `booth` CLI can write an ask and read an -answer without the service's venv. +A `pick` is one shape of MARK (see booth/marks.py): one of N options a session +declared in advance, chosen by the operator. This module owns what a declaration +is allowed to look like and what shape the recorded judgment takes; `marks.py` +owns where both are kept. -Filesystem is the state, same as everything else in the Booth: +The split exists because the storage changed and these semantics must not. They +are operator-settled (2026-09-09) and were moved rather than rewritten: - /.ask.json the question (written by a session) - /.answer.json the answer (written by the web UI) + normalize_ask(raw, id) -> dict validate a declaration; BOTH accepted + shapes come back as a `questions` list + build_answer(ask, ...) -> dict shape the operator's answer to one -Ask schema (what a session writes): +STDLIB ONLY, like links.py and marks.py: the `booth` CLI imports these under the +system python3 with no venv. + +Declaration, single-question (what a session writes): {"prompt": "Which render wins?", - "options": ["A — baseline", "B — cudaMallocAsync"], # ≥ 2, strings or + "options": ["A — baseline", "B — cudaMallocAsync"], # >= 2, strings or # [{"id": "a", "label": "A — baseline", "detail": "…"}, …] "notes": true, # optional, default true: show a free-text field "notes_label": "why?"} # optional placeholder for that field -Answer schema (what the operator's submit writes, atomically): - - {"stem": "winner", "prompt": "…", - "choice": "b", # the option id (== label for string options) - "choice_index": 1, # 0-based position in `options` - "label": "B — cudaMallocAsync", - "notes": "less banding on the gradient", - "answered_at": "2026-09-09T07:12:03-07:00", - "answered_by": "10.100.10.20"} - -Multi-question form (one submit, one sidecar): +Declaration, multi-question — ONE form, ONE submit: {"title": "R18 batch review", "questions": [{"key": "q1", "prompt": "Render 1?", "options": ["keep", "drop"], "notes": true}, {"key": "q2", "prompt": "Render 2?", "options": ["keep", "drop"]}], "notes": true} - -> {"stem", "title", "answers": {"q1": {"prompt", "choice", "choice_index", "label", "notes"}, …}, - "unanswered": ["q2"], "complete": false, "notes", "answered_at", "answered_by"} -A question left blank is legal: it lands in `unanswered` and is absent from -`answers` (unless it carried a note). `complete` is false until every question -has a pick. Only a submission with no pick AND no notes anywhere is refused. +The recorded judgment: -Re-answering overwrites: the sidecar is the current answer, not a log. A -session that wants history keeps its own. + single {"stem", "prompt", "choice", "choice_index", "label", + "unanswered", "complete", "notes", "answered_at", "answered_by"} + multi {"stem", "title", "answers": {"q1": {"prompt", "choice", + "choice_index", "label", "notes"}, …}, + "unanswered": ["q2"], "complete": false, "notes", …} + +PARTIAL ANSWERS ARE LEGAL, and this is the part most likely to be "cleaned up" +by someone who has not read the ruling. A question left blank is a deliberate +outcome — "none of these", "I have not listened to that one yet", "ask me +later" — and refusing a four-question submission because one was skipped threw +away the three that were made. So a blank question lands in `unanswered`, is +absent from `answers` unless it carried a note, and `complete` stays false. The +ONE refusal is a submission carrying nothing at all: no choice anywhere and no +notes, which would flip an open pick to answered while recording no decision. +An offered-but-invalid option is still an error — a broken form, not a skip. + +Re-answering overwrites: a mark is the CURRENT judgment, not a log. A session +that wants history keeps its own. + +`ASK_SUFFIX` / `ANSWER_SUFFIX` / `is_ask_file` / `is_answer_file` / `ask_stem` +survive for exactly two consumers: the legacy importer in marks.py, and +`booth_items`, which still excludes those files from the tile list because the +migration does not delete them. """ from __future__ import annotations import json -import os import re from datetime import datetime from pathlib import Path @@ -211,55 +223,6 @@ def normalize_ask(raw: dict, stem: str) -> dict: } -def load_ask(booth: Path, stem: str) -> dict: - """Parsed + normalised ask for `stem`. Raises AskError if unreadable/invalid.""" - path = Path(booth) / f"{stem}{ASK_SUFFIX}" - try: - raw = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - raise AskError("no such ask") - except (OSError, ValueError) as exc: - raise AskError(f"unreadable ask: {exc}") - return normalize_ask(raw, stem) - - -def read_answer(booth: Path, stem: str) -> dict | None: - path = Path(booth) / f"{stem}{ANSWER_SUFFIX}" - try: - data = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - return None - except (OSError, ValueError): - return None - return data if isinstance(data, dict) else None - - -def list_asks(booth: Path) -> list[dict]: - """Every ask in a booth (top level only), oldest first by file mtime, each - with its current answer folded in (`answer` is None while open). An invalid - ask file is returned with `error` set so the page can say so instead of - silently hiding the question a session thinks it posted.""" - booth = Path(booth) - out: list[dict] = [] - if not booth.is_dir(): - return out - files = [p for p in booth.iterdir() if p.is_file() and not p.name.startswith(".") and is_ask_file(p.name)] - files.sort(key=lambda p: (p.stat().st_mtime, p.name)) - for p in files: - stem = ask_stem(p.name) - try: - ask = load_ask(booth, stem) - except AskError as exc: - out.append({"stem": stem, "multi": False, "title": "", "prompt": None, "questions": [], - "options": [], "notes": False, "notes_label": "notes", - "error": str(exc), "answer": None}) - continue - ask["error"] = None - ask["answer"] = read_answer(booth, stem) - out.append(ask) - return out - - def _pick(options: list[dict], choice, where: str) -> tuple[int, dict]: idx = next((i for i, o in enumerate(options) if o["id"] == choice), None) if idx is None: @@ -278,11 +241,15 @@ def _clean_notes(text) -> str: return (text or "").replace("\r\n", "\n").strip()[:NOTES_MAX] -def write_answer(booth: Path, stem: str, choice, notes: str = "", who: str = "", +def build_answer(ask: dict, choice, notes: str = "", who: str = "", qnotes: dict | None = None) -> dict: - """Record the operator's answer. Validates the choices that were MADE, - writes `.answer.json` via temp-file + os.replace so a reader never - sees a half-written document. Returns the answer written. + """Shape the operator's answer to a NORMALIZED ask. Pure — no I/O; the + caller owns storage. Validates the choices that were MADE. + + This is `write_answer`'s logic with the storage removed, extracted so + `booth.marks` can own the storage without re-implementing the semantics + below. `stem` comes off the ask (`normalize_ask` emits it), so the two + callers do not have to agree on a second source for it. PARTIAL ANSWERS ARE LEGAL (operator ruling 2026-09-09). A question left blank is a deliberate outcome — "none of these", "I did not listen to that @@ -303,7 +270,7 @@ def write_answer(booth: Path, stem: str, choice, notes: str = "", who: str = "", {key: option id} dict for a multi-question ask. `qnotes` is {key: text} for per-question notes fields (multi only). """ - ask = load_ask(booth, stem) # raises AskError if the ask is gone/invalid + stem = ask["stem"] stamp = { "answered_at": datetime.now().astimezone().isoformat(timespec="seconds"), "answered_by": who or "", @@ -358,28 +325,4 @@ def write_answer(booth: Path, stem: str, choice, notes: str = "", who: str = "", "notes": form_notes, **stamp, } - path = Path(booth) / f"{stem}{ANSWER_SUFFIX}" - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(answer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - os.replace(tmp, path) return answer - - -def write_ask(booth: Path, stem: str, prompt: str | None = None, options: list | None = None, - notes: bool = True, notes_label: str = "notes", doc: dict | None = None) -> Path: - """Author an ask from code/CLI. Either (prompt, options, ...) for a - single-question ask, or `doc=` a full document (single or multi shape). - Validated through the same normaliser the renderer uses, so a session - cannot post a question the page would reject.""" - if not valid_stem(stem): - raise AskError("bad stem: letters, digits, . _ - only") - if doc is None: - doc = {"prompt": prompt, "options": options, "notes": notes, "notes_label": notes_label} - normalize_ask(doc, stem) - booth = Path(booth) - booth.mkdir(parents=True, exist_ok=True) - path = booth / f"{stem}{ASK_SUFFIX}" - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - os.replace(tmp, path) - return path diff --git a/booth/inline.py b/booth/inline.py index 060a074..c92fbb8 100644 --- a/booth/inline.py +++ b/booth/inline.py @@ -61,7 +61,7 @@ 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[dict], render) -> tuple[str, dict[str, set], set[str]]: +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 @@ -80,7 +80,12 @@ def place(html: str, asks: list[dict], render) -> tuple[str, dict[str, set], set blanked: silently eating the author's markup would hide a typo'd stem, and an untouched empty div is invisible anyway. """ - by_stem = {a["stem"]: a for a in asks} + # 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() @@ -93,7 +98,7 @@ def place(html: str, asks: list[dict], render) -> tuple[str, dict[str, set], set placed.setdefault(stem, set()).add(None) submitted.add(stem) return render("whole", ask, None) - q = next((q for q in ask.get("questions", []) if q.get("key") == key), 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) diff --git a/booth/marks.py b/booth/marks.py new file mode 100644 index 0000000..1f29338 --- /dev/null +++ b/booth/marks.py @@ -0,0 +1,581 @@ +"""Marks — ONE primitive for operator judgment attached to an artifact. + + An ask is the session asking the operator. An annotation is the operator + telling the session. A vote is the operator pointing at the good ones. + +All three are the same thing, and before this module they were three mechanisms: +asks had two JSON sidecars per question and a walk-the-booth read path, +annotations had nothing, and votes had nothing — so the operator picked winners +out of a 270-image set and told the session IN CHAT. `golden-candidates`, +`sindra-finalists` and the `pancake-*` ladders are all that loop, running +through conversation because the session that posted the set had no way to read +the judgment it asked for. + + MARK + target : the booth, or one item in it (an Item.rel — U1's identity, reused) + shape : pick — one of N options the session declared in advance + note — free text the operator volunteered + flag — this one + writer : the operator, in the browser + reader : the session — `booth marks [--wait]` + +One storage model (`.marks.json`), one read path (`marks_for`), one place +openness is computed (`open_marks`), one rendering slot (beside the artifact). + +STDLIB ONLY, like links.py and asks.py: `scripts/booth` imports this under the +system python3 with no venv. See docs/contracts/u2_marks.contract.md, INV-5. + +WHY ONE FILE PER BOOTH, and not a sidecar per mark (operator decision, +2026-09-21): U4 makes "does this booth still 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 it has to be one read, not a walk of a booth that may hold 270 files. +And note the writer roles: a session writes pick declarations, the operator +writes judgments. That is two roles on one file, so the flock below is +load-bearing. It is NOT links.md's problem, though — links.md is an O_APPEND +content-hash log because 17 handles write it concurrently and locking its common +path would serialize them. A booth's marks see one session and one operator, so +locking the common path costs nothing. Same lock, deliberately not the same shape. +""" + +from __future__ import annotations + +import fcntl +import json +import os +from dataclasses import asdict, dataclass, field +from datetime import datetime +from pathlib import Path +from typing import IO, Literal, Sequence + +from booth.asks import ( + ANSWER_SUFFIX, + ASK_SUFFIX, + AskError, + NOTES_MAX, + ask_stem, + build_answer, + is_ask_file, + normalize_ask, + valid_stem, +) + +MARKS_FILE = ".marks.json" +MARKS_LOCK = ".marks.lock" +SCHEMA_VERSION = 1 + +PICK = "pick" +NOTE = "note" +FLAG = "flag" +SHAPES = (PICK, NOTE, FLAG) + +TEXT_MAX = NOTES_MAX # a note is the same kind of text as an ask's notes field + +# A target is an Item.rel — a booth-relative POSIX path — or None for the booth +# itself. No second addressing scheme: U1 established `rel` as item identity and +# a mark that invented its own would need a translation layer nobody wants. +_TARGET_MAX = 1024 + + +@dataclass(frozen=True) +class Mark: + """One piece of operator judgment, with every fact any surface needs. + + Wide and flat on purpose. A nested shape-specific bag would make every + template navigate it, and the three shapes share more than they differ. + """ + + id: str + shape: str + target: str | None + created: str + # --- pick: the session's declaration, normalized on READ --- + declaration: dict | None = None + prompt: str | None = None + title: str = "" + multi: bool = False + questions: list[dict] = field(default_factory=list) + options: list[dict] = field(default_factory=list) + notes_enabled: bool = True + notes_label: str = "notes" + # --- the operator's judgment --- + answer: dict | None = None + text: str = "" + flagged: bool = False + by: str = "" + error: str | None = None + + @property + def is_open(self) -> bool: + """Whether this mark still owes the session an answer. Delegates to the + module predicate so there is exactly one of them (INV-2).""" + return _is_open(self) + + +def now_stamp() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def _clean_text(text) -> str: + return (text or "").replace("\r\n", "\n").strip()[:TEXT_MAX] + + +def flag_id(target: str) -> str: + """A flag's id is derived from its target, which is what makes flagging an + UPSERT: one item has at most one flag state, so there is nothing to + accumulate. Unflagging removes the mark rather than storing `false` — an + absent flag and a false flag are the same judgment, and two representations + of one state is how `.forever` became a problem.""" + return f"flag:{target}" + + +def _note_id(existing: set[str]) -> str: + """A note gets a generated id because an item may carry several.""" + n = 1 + while f"note-{n}" in existing: + n += 1 + return f"note-{n}" + + +def _valid_target(target) -> bool: + if target is None: + return True + if not isinstance(target, str) or not target or len(target) > _TARGET_MAX: + return False + # A target names a file inside the booth. Absolute paths and traversal are + # not "unlikely", they are the first thing a fuzzer tries. + if target.startswith("/") or ".." in Path(target).parts: + return False + return True + + +# ---- storage ---------------------------------------------------------------- + + +def _read_raw(booth: Path) -> list[dict]: + """The stored mark entries, or [] for missing/corrupt. + + A booth with no marks and a booth whose mark file is truncated both render + as "no marks", and neither is a 500 — the same posture `read_blurred` takes, + for the same reason: a review surface that will not load is worse than one + that has lost an annotation. + """ + try: + raw = json.loads((Path(booth) / MARKS_FILE).read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeDecodeError): + return [] + if not isinstance(raw, dict): + return [] + entries = raw.get("marks") + if not isinstance(entries, list): + return [] + return [e for e in entries if isinstance(e, dict) and isinstance(e.get("id"), str)] + + +def _fingerprint(entries: list[dict]) -> str: + """A stable serialization used ONLY to decide whether a write is a no-op.""" + return json.dumps(entries, sort_keys=True, ensure_ascii=False) + + +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} + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.replace(tmp, path) + + +class _Locked: + """Exclusive flock held across the whole read-modify-write. + + The lock lives on a sidecar dotfile rather than on `.marks.json` itself, + because the write path replaces that file — flock follows the inode, so + locking a file you are about to os.replace protects nothing after the swap. + Same reason `links.py` locks `.links.lock`. + """ + + def __init__(self, booth: Path): + self.booth = Path(booth) + self.entries: list[dict] = [] + self._lf: IO[str] | None = None + self._before: str = "" + self._made_lock = False + + def __enter__(self) -> "_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. + if not lock.exists(): + lock.touch() + self._made_lock = True + self._lf = lock.open("r+") + fcntl.flock(self._lf, fcntl.LOCK_EX) + self.entries = _read_raw(self.booth) + self._before = _fingerprint(self.entries) + return self + + def __exit__(self, exc_type, exc, tb) -> Literal[False]: + """Never suppresses. The annotation is `Literal[False]` rather than + `bool` on purpose: a `bool` tells a type checker this manager MIGHT + swallow an exception, and a swallowed write error would report success + on a mark that never reached disk.""" + lf = self._lf + assert lf is not None, "__exit__ without __enter__" + try: + # Write only if something actually changed. Marking IS activity and + # SHOULD reset the booth's TTL — but a write that changes nothing is + # not activity, and unflagging something that was never flagged + # 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) + finally: + fcntl.flock(lf, fcntl.LOCK_UN) + lf.close() + self._lf = None + return False + + def find(self, mark_id: str) -> dict | None: + return next((e for e in self.entries if e.get("id") == mark_id), None) + + +# ---- read ------------------------------------------------------------------- + + +def _hydrate(entry: dict) -> Mark: + """One stored entry -> one Mark, declarations normalized. + + A pick's declaration is stored RAW and normalized here, exactly as + `write_ask` + `load_ask` did: validated at write, re-read at render, so a + declaration that went bad on disk surfaces as `error` instead of being + unrepresentable. A broken question the session believes it posted has to be + visible — silently hiding it is the one outcome nobody can debug. + """ + mid = entry["id"] + shape = entry.get("shape") if entry.get("shape") in SHAPES else NOTE + target = entry.get("target") + if not _valid_target(target): + target = None + base = { + "id": mid, + "shape": shape, + "target": target, + "created": entry.get("created") or "", + "by": entry.get("by") or "", + } + + if shape == PICK: + decl = entry.get("declaration") + answer = entry.get("answer") if isinstance(entry.get("answer"), dict) else None + stored_error = entry.get("error") + if isinstance(stored_error, str) and stored_error: + # A reason recorded by whoever wrote the entry — the legacy importer + # discovers these, and the reason has to survive to the page or a + # question the session believes it posted disappears silently. + return Mark(**base, declaration=decl if isinstance(decl, dict) else None, + answer=answer, error=stored_error) + if not isinstance(decl, dict): + return Mark(**base, declaration=None, answer=answer, + error="pick has no declaration") + try: + norm = normalize_ask(decl, mid) + except AskError as exc: + return Mark(**base, declaration=decl, answer=answer, error=str(exc)) + return Mark( + **base, + declaration=decl, + prompt=norm["prompt"], + title=norm["title"], + multi=norm["multi"], + questions=norm["questions"], + options=norm.get("options", []), + notes_enabled=norm["notes"], # normalize_ask emits it as `notes` + notes_label=norm["notes_label"], + answer=answer, + ) + + if shape == FLAG: + return Mark(**base, flagged=True) + + return Mark(**base, text=_clean_text(entry.get("text"))) + + +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] + # (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)) + return marks + + +def _is_open(mark: Mark) -> bool: + """THE openness predicate. Nothing else may spell this out. + + A partially-answered pick is STILL OPEN. Today's index badge tests + `answer is None` and so calls a half-answered four-question pick closed, + while the panel beside it renders that same pick `◐ partial` — the two + disagree about one 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. + """ + if mark.shape != PICK or mark.error is not None: + return False + if mark.answer is None: + return True + return not mark.answer.get("complete", False) + + +def open_marks(marks: Sequence[Mark]) -> list[Mark]: + """The marks still owed an answer. The index badge, the booth header, the + panel filter and U4's pin rule all call this rather than re-deriving it.""" + return [m for m in marks if _is_open(m)] + + +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] + + +def as_dict(mark: Mark) -> dict: + """The JSON boundary — `booth marks` output. Python consumers take the + dataclass, and so does Jinja (every template accesses marks by attribute); + this exists so the CLI has one serialization instead of one per verb.""" + return asdict(mark) + + +# ---- write ------------------------------------------------------------------ + + +def declare_pick(booth: Path, mark_id: str, doc: dict) -> Mark: + """A session poses a pick. + + Validated through `normalize_ask` BEFORE anything is written, so a session + cannot land a question the renderer would refuse. Re-declaring an existing + id replaces the declaration and CLEARS its answer: the question changed, so + the old judgment is not an answer to it. + """ + if not valid_stem(mark_id): + raise AskError("bad mark id: letters, digits, . _ - only") + normalize_ask(doc, mark_id) # raises AskError; nothing written yet + with _Locked(booth) as lk: + existing = lk.find(mark_id) + if existing is not None and existing.get("shape") != PICK: + raise AskError(f"{mark_id!r} is already a {existing.get('shape')}") + entry = { + "id": mark_id, + "shape": PICK, + "target": existing.get("target") if existing else None, + "created": existing.get("created") if existing else now_stamp(), + "declaration": doc, + "answer": None, + } + if existing is None: + lk.entries.append(entry) + else: + lk.entries[lk.entries.index(existing)] = entry + return _hydrate(entry) + + +def answer_pick(booth: Path, mark_id: str, choice, notes: str = "", who: str = "", + qnotes: dict | None = None) -> Mark: + """Record the operator's pick. Every semantic belongs to + `asks.build_answer`; this function owns storage and nothing else. + + Re-answering overwrites — the mark is the CURRENT judgment, not a log. + + The AskError for an id that names no live pick is raised HERE. It used to + come from `load_ask` inside `write_answer`; extracting the answer-builder + moved the path to this caller, and a stale form POST has to be a 400 rather + than a silent no-op. + """ + with _Locked(booth) as lk: + entry = lk.find(mark_id) + if entry is None or entry.get("shape") != PICK: + raise AskError("no such pick") + decl = entry.get("declaration") + if not isinstance(decl, dict): + raise AskError("pick has no declaration") + ask = normalize_ask(decl, mark_id) # raises AskError on a bad declaration + entry["answer"] = build_answer(ask, choice, notes, who, qnotes) + return _hydrate(entry) + + +def write_note(booth: Path, target: str | None, text: str, who: str = "") -> Mark: + """Attach free text to an item, or to the booth itself. + + The operator telling the session — the direction that had no mechanism at + all before this, which is why the loop ran through chat. Several notes per + target are legal (a review makes more than one remark about one image), so + each gets a generated id rather than upserting like a flag. + + Empty text after cleaning is refused, the same posture an empty pick + submission takes: recording a mark that says nothing is strictly worse for + the reading session than recording no mark. + """ + if not _valid_target(target): + raise AskError("a note's target must be a path inside the booth") + body = _clean_text(text) + if not body: + raise AskError("nothing to record — the note is empty") + with _Locked(booth) as lk: + entry = { + "id": _note_id({e.get("id") for e in lk.entries}), + "shape": NOTE, + "target": target, + "created": now_stamp(), + "text": body, + "by": who or "", + } + lk.entries.append(entry) + return _hydrate(entry) + + +def set_flag(booth: Path, target: str, on: bool, who: str = "") -> Mark | None: + """Flag or unflag one item — the operator pointing at the good ones. + + This is the shape that makes a 270-image booth tractable, and the one that + closes a loop currently running through conversation: `golden-candidates`, + `sindra-finalists` and the `pancake-*` ladders are all the operator + selecting winners and then telling the session by hand. + + UPSERT keyed by target (see `flag_id`): flagging twice is idempotent, and + unflagging REMOVES the mark and returns None rather than storing a false. + Unflagging something that was never flagged is not an error — it is the + state the caller asked for. + """ + if not _valid_target(target) or target is None: + raise AskError("a flag's target must be a path inside the booth") + mid = flag_id(target) + with _Locked(booth) as lk: + entry = lk.find(mid) + if not on: + if entry is not None: + lk.entries.remove(entry) + return None + if entry is not None: + return _hydrate(entry) # already flagged; nothing to change + entry = { + "id": mid, + "shape": FLAG, + "target": target, + "created": now_stamp(), + "by": who or "", + } + lk.entries.append(entry) + return _hydrate(entry) + + +def delete_mark(booth: Path, mark_id: str) -> bool: + """Remove one mark by id — the operator's undo. True if it was there. + + Deleting a mark is the operator withdrawing a judgment, which is his to do. + Nothing else in this module deletes anything. + """ + with _Locked(booth) as lk: + entry = lk.find(mark_id) + if entry is None: + return False + lk.entries.remove(entry) + return True + + +# ---- migration -------------------------------------------------------------- + + +def import_legacy_asks(booth: Path) -> list[Mark]: + """Read every `*.ask.json` / `*.answer.json` in a booth into `.marks.json`. + + EXPLICIT AND ONE-SHOT, not lazy. A read that writes would fire on every + index page load for every booth, which is the wrong trade for the four + sidecars that exist on the live service. + + IDEMPOTENT: an id already present as a mark is skipped outright, so a second + run is a no-op and a judgment recorded since the first run is never + clobbered by the older sidecar. + + NOTHING IS DELETED. The sidecars stay on disk — the ROADMAP's non-goal is + explicit about it, and `booth_items` already excludes them from the tile + list, so an imported-but-kept sidecar does not show up as a file. + + Returns the marks it created, oldest first. `created` is seeded from the + sidecar's MTIME rather than from now(): `list_asks` ordered by mtime and + `marks_for` orders by `created`, so seeding from now() would silently + reshuffle a booth's questions at the moment of migration. + """ + booth = Path(booth) + if not booth.is_dir(): + return [] + + found: list[tuple[float, str, dict | None, str | None]] = [] + for p in sorted(booth.iterdir()): + if not p.is_file() or p.name.startswith(".") or not is_ask_file(p.name): + continue + stem = ask_stem(p.name) + if not valid_stem(stem): + # Could not have been written by `booth ask`. Left alone rather than + # imported under an id nothing could address. + continue + # A sidecar that cannot be read is imported WITH ITS REASON rather than + # skipped. `list_asks` surfaced these as `⚠ broken` on the page, and + # dropping them on migration would turn a visible broken question into a + # question that was never there — found by a retargeted test, which is + # the whole argument for retargeting them instead of deleting them. + try: + mtime = p.stat().st_mtime + except OSError: + continue + try: + decl = json.loads(p.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeDecodeError) as exc: + found.append((mtime, stem, None, f"unreadable ask: {exc}")) + continue + if not isinstance(decl, dict): + found.append((mtime, stem, None, "ask must be a JSON object")) + continue + found.append((mtime, stem, decl, None)) + + if not found: + return [] + found.sort(key=lambda t: (t[0], t[1])) # mtime, then name — never compares payloads + + created: list[dict] = [] + with _Locked(booth) as lk: + have = {e.get("id") for e in lk.entries} + for mtime, stem, decl, err in found: + if stem in have: + continue + answer = None + ap = booth / f"{stem}{ANSWER_SUFFIX}" + try: + loaded = json.loads(ap.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + answer = loaded + except (OSError, ValueError, UnicodeDecodeError): + pass + entry = { + "id": stem, + "shape": PICK, + "target": None, + "created": datetime.fromtimestamp(mtime).astimezone().isoformat(timespec="seconds"), + "declaration": decl, + "answer": answer, + } + if err: + entry["error"] = err + lk.entries.append(entry) + created.append(entry) + + # 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] diff --git a/booth/templates/_ask_inline.html b/booth/templates/_ask_inline.html index f5c406e..192212b 100644 --- a/booth/templates/_ask_inline.html +++ b/booth/templates/_ask_inline.html @@ -57,7 +57,7 @@ {% set qa = (a.answer.answers.get(q.key) if a.multi else a.answer) if a.answer else None %} {% set picked = qa and qa.choice is not none %} {% set skipped = a.answer and not picked %} -
+
{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}

{{ q.prompt }}

{% if picked %}

recorded: {{ qa.label }}{% if qa.notes %} — {{ qa.notes }}{% endif %}

@@ -84,14 +84,14 @@ {# The form element + hidden fields + overall notes + submit. Empty
on purpose: the question groups above bind to it by id from wherever they sit. #} {% macro submit(a, form_id, name_url) %} -
+
- + {% 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 }} {%- else %}? submit your picks{% endif %} {% if not a.answer %}

Answer what you can — blanks are fine, and you can come back.

{% endif %} - {% if a.notes %} + {% if a.notes_enabled %} {% endif %} @@ -103,9 +103,9 @@ {% macro whole(a, form_id, name_url) %} {% if a.error %}
⚠ broken ask -

{{ a.stem }}.ask.json could not be read: {{ a.error }}

+

this question could not be read: {{ a.error }}

{% else %} - {% if a.title %}

{{ a.title }}

{% endif %} + {% if a.title %}

{{ a.title }}

{% endif %} {% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %} {{ submit(a, form_id, name_url) }} {% endif %} diff --git a/booth/templates/_asks.html b/booth/templates/_asks.html deleted file mode 100644 index 6577767..0000000 --- a/booth/templates/_asks.html +++ /dev/null @@ -1,81 +0,0 @@ -{# Shared asks panel — included by booth.html (auto-gallery view) and by - asks.html (the standalone page a VERBATIM index.html booth links to, since - a verbatim page is served as-is and can never render this inline). #} - {# ASKS. A session left multiple-choice questions here for the operator - (`.ask.json`). Open ones render as a radio form; answering POSTs to - /answer, which writes `.answer.json` for the session to read. Works - with JS off — plain form POST. Answered asks show the recorded answer and a - collapsed "change" form, since the sidecar is the CURRENT answer. #} -
- {% for a in asks %} -
-
- {% if a.error %}⚠ broken{% elif a.answer and a.answer.complete %}✓ answered{% elif a.answer %}◐ partial{% else %}? open{% endif %} - {{ a.stem }}.ask.json{% if a.multi %} · {{ a.questions|length }} questions{% endif %} - - {% if a.answer and not a.answer.complete %}{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}{% endif %} - {% if a.answer %}{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}{% endif %} -
- {% if a.error %} -

This ask could not be read: {{ a.error }}

- {% else %} - {% if a.title and not a.multi %}

{{ a.title }}

{% endif %} -

{{ a.prompt }}

- {% if a.answer %} -
- {% if a.multi %} - {% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %} -
- {{ q.prompt }} -
{{ qa.label if (qa and qa.choice is not none) else 'left blank' }}
- {% if qa and qa.notes %}
{{ qa.notes }}
{% endif %} -
- {% endfor %} - {% else %} -
{{ a.answer.label }}
- {% endif %} - {% if a.answer.notes %}
{{ a.answer.notes }}
{% endif %} - → {{ a.stem }}.answer.json -
- {% endif %} -
- {% if a.answer %}change answer{% else %}answer{% endif %} -
- - {# On the standalone page, come back HERE — the booth's own page is a - verbatim report that cannot show the recorded answer. #} - {% if asks_page %}{% 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 %} -
- {% if a.multi %}{{ loop.index }}. {{ q.prompt }}{% endif %} -
- {% for o in q.options %} - - {% endfor %} -
- {% if q.notes %} - - {% endif %} -
- {% endfor %} - {% if a.notes %} - - {% endif %} -
- -
-
-
- {% endif %} -
- {% endfor %} -
diff --git a/booth/templates/_marks.html b/booth/templates/_marks.html new file mode 100644 index 0000000..cd2c5bc --- /dev/null +++ b/booth/templates/_marks.html @@ -0,0 +1,135 @@ +{# Shared MARKS panel — included by booth.html (auto-gallery view) and by + marks.html (the standalone page a VERBATIM index.html booth links to, since + a verbatim page is served as-is and can never render this inline). + + One primitive, three shapes, one slot: + pick — a session declared N options; the operator chooses. Renders as a + radio form; answering POSTs to /answer and rewrites .marks.json. + note — free text the operator volunteered, in either direction. + flag — the operator pointing at one item. Rendered on the item's tile + rather than here, so the judgment sits beside the artifact; the + count below is the way back to them. + + 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. #} +{% set picks = marks | selectattr('shape', 'equalto', 'pick') | list %} +{% set notes = marks | selectattr('shape', 'equalto', 'note') | list %} +{% set flags = marks | selectattr('shape', 'equalto', 'flag') | list %} +
+ +{% for a in picks %} +
+
+ {% if a.error %}⚠ broken{% elif a.answer and a.answer.complete %}✓ answered{% elif a.answer %}◐ partial{% else %}? open{% endif %} + {{ a.id }}{% if a.multi %} · {{ a.questions|length }} questions{% endif %} + {% if a.target %}on {{ a.target }}{% endif %} + + {% if a.answer and not a.answer.complete %}{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}{% endif %} + {% if a.answer %}{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}{% endif %} +
+ {% if a.error %} +

This question could not be read: {{ a.error }}

+ {% else %} + {% if a.title and not a.multi %}

{{ a.title }}

{% endif %} +

{{ a.prompt }}

+ {% if a.answer %} +
+ {% if a.multi %} + {% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %} +
+ {{ q.prompt }} +
{{ qa.label if (qa and qa.choice is not none) else 'left blank' }}
+ {% if qa and qa.notes %}
{{ qa.notes }}
{% endif %} +
+ {% endfor %} + {% else %} +
{{ a.answer.label }}
+ {% endif %} + {% if a.answer.notes %}
{{ a.answer.notes }}
{% endif %} +
+ {% endif %} +
+ {% if a.answer %}change answer{% else %}answer{% endif %} +
+ {# 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. #} + + {# 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 %}{% 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 %} +
+ {% if a.multi %}{{ loop.index }}. {{ q.prompt }}{% endif %} +
+ {% for o in q.options %} + + {% endfor %} +
+ {% if q.notes %} + + {% endif %} +
+ {% endfor %} + {% if a.notes_enabled %} + + {% endif %} +
+ +
+
+
+ {% endif %} +
+{% endfor %} + +{% for a in notes %} +
+
+ note + {% if a.target %}on {{ a.target }} + {% else %}on this booth{% endif %} + + {{ a.created }}{% if a.by %} · {{ a.by }}{% endif %} +
+ + {% if marks_page %}{% endif %} + +
+
+
{{ a.text }}
+
+{% endfor %} + +{% if flags %} +
+
+ ✔ flagged + {{ flags|length }} item{{ '' if flags|length == 1 else 's' }} +
+ +
+{% endif %} + +{# The operator volunteering a remark, which before marks had no mechanism at + all — this is the direction that was running through chat. #} +
+ {% if marks_page %}{% endif %} + + +
+
diff --git a/booth/templates/asks.html b/booth/templates/asks.html deleted file mode 100644 index bc2fc36..0000000 --- a/booth/templates/asks.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ name }} · asks · The Booth{% endblock %} -{% block content %} -{# The asks 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 - injected chip links here instead. Same forms, same POST target — only the - redirect differs, so answering lands back here rather than on the report. #} -
- ‹ {{ name }} -

Asks

- {% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %} - {% if open_asks %}{{ open_asks }} open · {% endif %}{{ asks|length }} ask{{ '' if asks|length == 1 else 's' }} -
-{% if asks %} - {% include "_asks.html" %} -{% else %} -
This booth has no asks.
-{% endif %} -{% endblock %} diff --git a/booth/templates/base.html b/booth/templates/base.html index c6bfe5b..55a52c4 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -313,72 +313,93 @@ Amber = "needs you" while open (the one colour the page does not otherwise use for state), green check once answered; the accent is a TOP edge, per Australis, never a coloured left border. */ - .badge-ask{background:var(--aus-bright-yellow);color:var(--fg-on-accent)} - .thumb .badge+.badge-ask{top:2.2rem} - .asks{display:flex;flex-direction:column;gap:.9rem;margin:.2rem 0 1.4rem} - .ask{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow); + .badge-mark{background:var(--aus-bright-yellow);color:var(--fg-on-accent)} + .thumb .badge+.badge-mark{top:2.2rem} + .marks{display:flex;flex-direction:column;gap:.9rem;margin:.2rem 0 1.4rem} + .mark{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow); border-radius:.5rem;background:var(--rk-panel);overflow:hidden} - .ask.is-answered{border-top-color:var(--aus-bright-green)} + .mark.is-answered{border-top-color:var(--aus-bright-green)} /* Partial: answered SOME questions. Not a failure and not done — blanks are a legal outcome (operator ruling 2026-09-09), so it gets its own state rather than being forced into one of the other two. */ - .ask.is-partial{border-top-color:var(--aus-bright-blue)} - .ask.is-partial .ask-state{color:var(--aus-bright-blue)} - .ask-part{font-family:var(--font-mono);font-size:.68rem;color:var(--aus-bright-blue);font-weight:700} - .ask-answer-choice.is-skipped{opacity:.55;font-style:italic} - .ask-answer-choice.is-skipped::before{content:"— ";color:var(--fg-3)} - .ask.is-broken{border-top-color:var(--aus-bright-red)} - .ask-head{display:flex;align-items:center;gap:.6rem;padding:.4rem .8rem; + .mark.is-partial{border-top-color:var(--aus-bright-blue)} + .mark.is-partial .mark-state{color:var(--aus-bright-blue)} + .mark-part{font-family:var(--font-mono);font-size:.68rem;color:var(--aus-bright-blue);font-weight:700} + .mark-answer-choice.is-skipped{opacity:.55;font-style:italic} + .mark-answer-choice.is-skipped::before{content:"— ";color:var(--fg-3)} + .mark.is-broken{border-top-color:var(--aus-bright-red)} + .mark-head{display:flex;align-items:center;gap:.6rem;padding:.4rem .8rem; border-bottom:1px solid var(--border-subtle);background:var(--rk-well); font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)} - .ask-state{font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--aus-bright-yellow)} - .ask.is-answered .ask-state{color:var(--aus-bright-green)} - .ask.is-broken .ask-state{color:var(--aus-bright-red)} - .ask-when{white-space:nowrap} - .ask-title{margin:.8rem .9rem -.35rem;font-family:var(--font-mono);font-size:.7rem; + .mark-state{font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--aus-bright-yellow)} + .mark.is-answered .mark-state{color:var(--aus-bright-green)} + .mark.is-broken .mark-state{color:var(--aus-bright-red)} + .mark-when{white-space:nowrap} + /* ---- marks: the per-item controls and the note slots ------------------- */ + .mark-state-note{color:var(--aus-bright-cyan,#42dcd1)} + .mark-state-flag{color:var(--aus-bright-green)} + .mark-text,.vnote pre,.item-note pre{margin:.3rem 0;padding:.5rem .7rem;white-space:pre-wrap; + background:var(--bg-2,rgba(128,140,160,.10));border-radius:6px;font-size:.82rem} + .mark-flaglist{margin:.3rem 0 .2rem;padding-left:1.1rem;font-size:.84rem} + .mark-add,.vaddnote,.item-addnote form{display:flex;gap:.5rem;align-items:flex-start;margin:.6rem 0} + .mark-add textarea,.vaddnote textarea,.item-addnote textarea{flex:1;min-width:0} + .mark-x{background:none;border:0;color:var(--fg-3);cursor:pointer;font-size:1rem;line-height:1;padding:0 .3rem} + .mark-x:hover{color:var(--aus-bright-red)} + .mark-undo{margin-left:.4rem} + .flagtoggle button{background:none;border:1px solid var(--border-subtle);border-radius:6px; + padding:.12rem .45rem;font-size:.72rem;color:var(--fg-2);cursor:pointer} + .flagtoggle button:hover{border-color:var(--aus-bright-green);color:var(--aus-bright-green)} + .item.is-flagged{outline:2px solid var(--aus-bright-green);outline-offset:2px} + .item-note{display:flex;align-items:flex-start;gap:.3rem} + .item-addnote summary{cursor:pointer;font-size:.72rem;color:var(--fg-3);padding:.15rem 0} + .vmarks{margin:.6rem auto 0;max-width:min(92vw,900px);display:flex;flex-direction:column;gap:.4rem} + .vflag{display:flex} + .vbtn.is-flagged{color:var(--aus-bright-green);border-color:var(--aus-bright-green)} + .vnote{display:flex;align-items:flex-start;gap:.3rem} + .mark-title{margin:.8rem .9rem -.35rem;font-family:var(--font-mono);font-size:.7rem; letter-spacing:.08em;text-transform:uppercase;color:var(--fg-3)} - .ask-prompt{margin:.85rem .9rem .5rem;font-size:1.02rem;font-weight:600;color:var(--fg-0);white-space:pre-wrap} - .ask-error{margin:.8rem .9rem;color:var(--aus-bright-red);font-size:.85rem} - .ask-answer{margin:.2rem .9rem .6rem;padding:.55rem .75rem;border:1px solid var(--border-subtle); + .mark-prompt{margin:.85rem .9rem .5rem;font-size:1.02rem;font-weight:600;color:var(--fg-0);white-space:pre-wrap} + .mark-error{margin:.8rem .9rem;color:var(--aus-bright-red);font-size:.85rem} + .mark-answer{margin:.2rem .9rem .6rem;padding:.55rem .75rem;border:1px solid var(--border-subtle); border-radius:var(--radius-md);background:rgba(81,224,138,.06)} - .ask-answer-choice{font-weight:600;color:var(--fg-0)} - .ask-answer-choice::before{content:"✓ ";color:var(--aus-bright-green)} - .ask-answer-notes{margin:.4rem 0 0;white-space:pre-wrap;font-family:var(--font-sans);font-size:.86rem; + .mark-answer-choice{font-weight:600;color:var(--fg-0)} + .mark-answer-choice::before{content:"✓ ";color:var(--aus-bright-green)} + .mark-answer-notes{margin:.4rem 0 0;white-space:pre-wrap;font-family:var(--font-sans);font-size:.86rem; color:var(--fg-1)} - .ask-answer-file{display:block;margin-top:.35rem;font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)} - .ask-answer-file a{color:var(--fg-2)} - .ask-formwrap{margin:0 .9rem .8rem} - .ask-change{cursor:pointer;font-family:var(--font-mono);font-size:.7rem;letter-spacing:.06em; + .mark-answer-file{display:block;margin-top:.35rem;font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)} + .mark-answer-file a{color:var(--fg-2)} + .mark-formwrap{margin:0 .9rem .8rem} + .mark-change{cursor:pointer;font-family:var(--font-mono);font-size:.7rem;letter-spacing:.06em; text-transform:uppercase;color:var(--fg-3);list-style:none;user-select:none} - .ask-change::-webkit-details-marker{display:none} - .ask-formwrap[open]>.ask-change{margin-bottom:.4rem} - .ask-formwrap:not([open])>.ask-change{color:var(--aus-bright-cyan)} - .ask-q{border:0;margin:0 0 .7rem;padding:0;min-width:0} - .ask-q:last-of-type{margin-bottom:0} - .ask-q-prompt{padding:0;margin:0 0 .35rem;font-size:.9rem;font-weight:600;color:var(--fg-0)} - .ask-qnotes{margin-top:.35rem;font-size:.82rem} - .ask-answer-q{padding:.3rem 0;border-bottom:1px dashed var(--border-subtle)} - .ask-answer-q:last-of-type{border-bottom:0} - .ask-answer-qprompt{display:block;font-size:.76rem;color:var(--fg-3)} - .ask-options{display:flex;flex-direction:column;gap:.35rem} - .ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.5rem .65rem;cursor:pointer; + .mark-change::-webkit-details-marker{display:none} + .mark-formwrap[open]>.mark-change{margin-bottom:.4rem} + .mark-formwrap:not([open])>.mark-change{color:var(--aus-bright-cyan)} + .mark-q{border:0;margin:0 0 .7rem;padding:0;min-width:0} + .mark-q:last-of-type{margin-bottom:0} + .mark-q-prompt{padding:0;margin:0 0 .35rem;font-size:.9rem;font-weight:600;color:var(--fg-0)} + .mark-qnotes{margin-top:.35rem;font-size:.82rem} + .mark-answer-q{padding:.3rem 0;border-bottom:1px dashed var(--border-subtle)} + .mark-answer-q:last-of-type{border-bottom:0} + .mark-answer-qprompt{display:block;font-size:.76rem;color:var(--fg-3)} + .mark-options{display:flex;flex-direction:column;gap:.35rem} + .mark-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.5rem .65rem;cursor:pointer; border:1px solid var(--border-subtle);border-radius:var(--radius-md);background:var(--rk-well); transition:border-color .12s,background .12s} - .ask-opt:hover{border-color:var(--border-strong)} - .ask-opt:has(input:checked){border-color:var(--aus-bright-cyan);background:rgba(66,220,209,.07)} - .ask-opt input{margin:.2rem 0 0;accent-color:var(--aus-bright-cyan);flex:0 0 auto} - .ask-opt-main{display:flex;flex-direction:column;gap:.1rem;min-width:0} - .ask-opt-label{font-size:.92rem;color:var(--fg-0)} - .ask-opt-detail{font-size:.76rem;color:var(--fg-3);white-space:pre-wrap} - .ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem; + .mark-opt:hover{border-color:var(--border-strong)} + .mark-opt:has(input:checked){border-color:var(--aus-bright-cyan);background:rgba(66,220,209,.07)} + .mark-opt input{margin:.2rem 0 0;accent-color:var(--aus-bright-cyan);flex:0 0 auto} + .mark-opt-main{display:flex;flex-direction:column;gap:.1rem;min-width:0} + .mark-opt-label{font-size:.92rem;color:var(--fg-0)} + .mark-opt-detail{font-size:.76rem;color:var(--fg-3);white-space:pre-wrap} + .mark-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem; font:inherit;font-size:.88rem;color:var(--fg-0);background:var(--rk-well); border:1px solid var(--border-subtle);border-radius:var(--radius-md);resize:vertical} - .ask-notes:focus{outline:none;border-color:var(--aus-bright-cyan);box-shadow:var(--glow-cyan)} - .ask-actions{display:flex;justify-content:flex-end;margin-top:.6rem} - .ask-submit{cursor:pointer;font-family:var(--font-mono);font-size:.74rem;letter-spacing:.06em; + .mark-notes:focus{outline:none;border-color:var(--aus-bright-cyan);box-shadow:var(--glow-cyan)} + .mark-actions{display:flex;justify-content:flex-end;margin-top:.6rem} + .mark-submit{cursor:pointer;font-family:var(--font-mono);font-size:.74rem;letter-spacing:.06em; padding:.42rem .9rem;border-radius:var(--radius-sm);border:1px solid var(--aus-bright-cyan); background:var(--aus-bright-cyan);color:var(--fg-on-accent);font-weight:700;transition:.14s var(--ease-out)} - .ask-submit:hover{background:var(--aus-cyan);border-color:var(--aus-cyan)} + .mark-submit:hover{background:var(--aus-cyan);border-color:var(--aus-cyan)} /* booth page */ .boothhead{display:flex;align-items:center;gap:1rem;flex-wrap:wrap; diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 4958aea..203c192 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -13,12 +13,50 @@ {%- 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 %} +
+ + + +
+{%- endmacro %} + +{# An item's notes, rendered BESIDE the artifact — the 2026-09-09 ruling that a + judgment belongs with the thing it is about, applied to notes as well as + picks. The add-field is a
so 270 tiles do not each carry an open + textarea. #} +{% macro marknotes(name_url, it, marks) -%} + {% for m in marks if m.shape == 'note' %} +
+
{{ m.text }}
+
+ + +
+
+ {% endfor %} +
+ + note +
+ + + +
+
+{%- endmacro %} + {% block title %}{{ name }} · The Booth{% endblock %} {% block content %}
‹ all booths

{{ name }}

- {% if uploaded %}⬆ pickup {% 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 %}{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}{% if open_asks %}{{ open_asks }} open ask{{ '' if open_asks == 1 else 's' }} · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %} + {% if uploaded %}⬆ pickup {% 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 %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %} {% if items %}⬇ zip{% endif %} {# 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 @@ -52,8 +90,12 @@
{% endif %} -{% if asks %} - {% include "_asks.html" %} +{# The marks panel: the session's questions, the operator's notes, and the way + 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 %} + {% include "_marks.html" %} {% endif %} {% if board %} @@ -108,7 +150,7 @@ {% endif %} -{% if not items and not board and not asks %} +{% if not items and not board and not marks %}
This booth is empty.
{% elif items %} {# `elif items` and not a bare `else`: a board booth has NO gallery items (its @@ -121,7 +163,7 @@ separate page.
is native collapse (works with JS off); the ✕ hides the item for the session (JS, progressive enhancement). The item spans the full grid width so prose has room to read. #} -
+
{% 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 @@ -137,6 +179,7 @@ ⤢ ⬇ {{ blurtoggle(name_url, it, 'doc-act') }} + {{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }} {% if it.rendered_html %} @@ -147,7 +190,7 @@
{% else %} -
+
{% 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 @@ -175,13 +218,17 @@
{% if it.caption %}{{ it.caption }}{% endif %} {{ blurtoggle(name_url, it) }} + {{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
+ {{ marknotes(name_url, it, item_marks.get(it.name, [])) }} {% else %}
⬇ {{ it.caption or it.name }} {{ blurtoggle(name_url, it) }} + {{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
+ {{ marknotes(name_url, it, item_marks.get(it.name, [])) }} {% endif %}
{% endif %} diff --git a/booth/templates/doc.html b/booth/templates/doc.html index 66bed5a..0c96321 100644 --- a/booth/templates/doc.html +++ b/booth/templates/doc.html @@ -11,6 +11,11 @@ {# Same record, same reason as the image viewer: the sidecar that says what this doc IS travels with it to full-page view. #} {% if caption %}
{{ caption }}
{% endif %} + {% if marks %} +
+ {% for m in marks if m.shape == 'note' %}
{{ m.text }}
{% endfor %} +
+ {% endif %} {% if is_html %}
{{ body|safe }}
{% else %} diff --git a/booth/templates/index.html b/booth/templates/index.html index 5b397de..ae43c4a 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -105,7 +105,7 @@
◆ files
{% endif %} {% if b.uploaded %}⬆ pickup{% endif %} - {% if b.asks_open %}? {{ b.asks_open }} ask{{ '' if b.asks_open == 1 else 's' }}{% endif %} + {% if b.marks_open %}? {{ b.marks_open }} open{% endif %}
{{ b.name }} diff --git a/booth/templates/marks.html b/booth/templates/marks.html new file mode 100644 index 0000000..7cabb31 --- /dev/null +++ b/booth/templates/marks.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}{{ name }} · marks · The Booth{% 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 + injected chip links here instead. Same forms, same POST targets — only the + redirect differs, so answering lands back here rather than on the report. #} +
+ ‹ {{ name }} +

Marks

+ {# `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. #} + {% if marks_open %}{{ marks_open }} open · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} +
+{% if marks %} + {% include "_marks.html" %} +{% else %} +
This booth has no marks.
+ {% include "_marks.html" %} +{% endif %} +{% endblock %} diff --git a/booth/templates/view.html b/booth/templates/view.html index b631b6f..a99e866 100644 --- a/booth/templates/view.html +++ b/booth/templates/view.html @@ -19,6 +19,31 @@ 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 %}
{{ caption }}
{% 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. #} +
+
+ + + +
+ {% for m in marks if m.shape == 'note' %} +
{{ m.text }}
+
+ + +
+
+ {% endfor %} +
+ + + +
+