From c7f9437a64b0ea52eb67ae1b76a4a02967a1065a Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Mon, 21 Sep 2026 23:38:27 -0700 Subject: [PATCH] feat(marks): one primitive for operator judgment, so the loop stops running through chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five mechanisms existed to get one question next to one artifact. Three of them were the same thing wearing different clothes, and the third of the three had no code at all: the operator picked winners out of a 270-image set and told the session in conversation. `sindra-finalists` is 86 items, every one captioned, with the selection encoded in the booth's NAME. A MARK is operator judgment attached to a target — the booth, or one item in it, addressed by the `rel` U1 established as item identity. Three shapes: pick — one of N options a session declared in advance (was: an ask) note — free text the operator volunteered (had nothing) flag — this one (had nothing) One file per booth, one read path, one place openness is computed, one slot beside the artifact. The storage shape is the operator's call (2026-09-21) and follows from U4: "does this booth still owe an answer?" gets asked per booth per sweep tick and per card per index render, so it has to be one read and not a walk of a booth holding 270 files. Marks are also not links.md — that is an O_APPEND content-hash log because 17 handles write it concurrently, whereas a booth's marks see one session and one operator, so locking the common path costs nothing. The 2026-09-09 pick semantics are preserved by NOT rewriting them: partial answers legal, a blank question lands in `unanswered`, `complete` false until every question has a pick, the only refusal a submission carrying nothing. `write_answer` split into the pure `build_answer` plus the storage that went away with the sidecar; `normalize_ask` untouched. Three findings worth naming, because each was caught by a gate rather than by reading the diff again: * The seam review found `inline.place` indexes asks by SUBSCRIPT — the only consumer in the service that does — so a frozen dataclass breaks it, and `inline.py` had been missing from the contract's scope entirely. * A retargeted test found a regression in the legacy importer: a malformed sidecar that renders "broken" today would have silently vanished on migration. It now imports carrying its reason. * A partially-answered pick counted as CLOSED on the index while the panel beside it rendered it "partial" — the two disagreed about one booth. Open is the reading U4 needs, and it is declared rather than smuggled in. `GET /b//marks.json` is new and load-bearing: sessions on other hosts polled `.answer.json` over HTTP, so removing the sidecar without it would have taken that capability away. `/b//asks` 308s to `/marks`. Legacy sidecars are imported, never deleted — four are live and unanswered. Also records the operator's deterministic-order directive as a cross-cutting v1 invariant, in ROADMAP.md with the per-collection rule table and as CLAUDE.md invariant 6. The Booth's job is comparison; an order that moves between renders does not crash, it misfiles the judgment. 242 tests. No version bump — a release tier for this is the operator's call. --- .gitignore | 1 + CLAUDE.md | 49 +- README.md | 205 +++----- ROADMAP.md | 37 ++ booth/app.py | 290 ++++++++--- booth/asks.py | 155 ++---- booth/inline.py | 11 +- booth/marks.py | 581 ++++++++++++++++++++++ booth/templates/_ask_inline.html | 12 +- booth/templates/_asks.html | 81 ---- booth/templates/_marks.html | 135 ++++++ booth/templates/asks.html | 19 - booth/templates/base.html | 121 +++-- booth/templates/booth.html | 59 ++- booth/templates/doc.html | 5 + booth/templates/index.html | 2 +- booth/templates/marks.html | 22 + booth/templates/view.html | 25 + docs/contracts/u2_marks.contract.md | 322 +++++++++++++ persistent-memory.md | 73 ++- scripts/booth | 168 ++++--- tests/test_asks.py | 239 ++++++---- tests/test_marks.py | 715 ++++++++++++++++++++++++++++ 23 files changed, 2677 insertions(+), 650 deletions(-) create mode 100644 booth/marks.py delete mode 100644 booth/templates/_asks.html create mode 100644 booth/templates/_marks.html delete mode 100644 booth/templates/asks.html create mode 100644 booth/templates/marks.html create mode 100644 docs/contracts/u2_marks.contract.md create mode 100644 tests/test_marks.py 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 %} +
+ + + +
+