feat(marks): one primitive for operator judgment, so the loop stops running through chat

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/<n>/marks.json` is new and load-bearing: sessions on other hosts polled
`<stem>.answer.json` over HTTP, so removing the sidecar without it would have
taken that capability away. `/b/<n>/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.
This commit is contained in:
vh
2026-09-21 23:38:27 -07:00
parent 9272c9872e
commit c7f9437a64
23 changed files with 2677 additions and 650 deletions
+215 -75
View File
@@ -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 `<stem>.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.<key>` per question, optional `notes.<key>`,
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'<a id="bk-ask-{ask["stem"]}-top"></a>' + frag
if mark.id not in seen:
seen.add(mark.id)
frag = f'<a id="bk-ask-{mark.id}-top"></a>' + 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
`<stem>.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":
+49 -106
View File
@@ -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:
<booth>/<stem>.ask.json the question (written by a session)
<booth>/<stem>.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 `<stem>.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
+8 -3
View File
@@ -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)
+581
View File
@@ -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 <booth> [--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]
+6 -6
View File
@@ -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 %}
<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.stem }}{% if q.key %}-{{ q.key }}{% endif %}">
<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}">
<span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span>
<p class="bk-ask-prompt">{{ q.prompt }}</p>
{% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>
@@ -84,14 +84,14 @@
{# The form element + hidden fields + overall notes + submit. Empty <form> on
purpose: the question groups above bind to it by id from wherever they sit. #}
{% macro submit(a, form_id, name_url) %}
<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.stem }}-submit">
<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.id }}-submit">
<form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
<input type="hidden" name="ask" value="{{ a.stem }}" form="{{ form_id }}">
<input type="hidden" name="ask" value="{{ a.id }}" form="{{ form_id }}">
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered {{ a.answer.answered_at }}
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · {{ a.answer.answered_at }}
{%- else %}? submit your picks{% endif %}</span>
{% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
{% if a.notes %}
{% if a.notes_enabled %}
<textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}"
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
{% endif %}
@@ -103,9 +103,9 @@
{% macro whole(a, form_id, name_url) %}
{% if a.error %}
<div class="bk-ask"><span class="bk-ask-tag">⚠ broken ask</span>
<p class="bk-ask-err">{{ a.stem }}.ask.json could not be read: {{ a.error }}</p></div>
<p class="bk-ask-err">this question could not be read: {{ a.error }}</p></div>
{% else %}
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.stem }}">{{ a.title }}</p>{% endif %}
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.id }}">{{ a.title }}</p>{% endif %}
{% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %}
{{ submit(a, form_id, name_url) }}
{% endif %}
-81
View File
@@ -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
(`<stem>.ask.json`). Open ones render as a radio form; answering POSTs to
/answer, which writes `<stem>.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. #}
<section class="asks">
{% for a in asks %}
<article class="ask{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="ask-{{ a.stem }}">
<header class="ask-head">
<span class="ask-state">{% if a.error %}⚠ broken{% elif a.answer and a.answer.complete %}✓ answered{% elif a.answer %}◐ partial{% else %}? open{% endif %}</span>
<span class="ask-stem"><code>{{ a.stem }}.ask.json</code>{% if a.multi %} · {{ a.questions|length }} questions{% endif %}</span>
<span class="board-spacer"></span>
{% if a.answer and not a.answer.complete %}<span class="ask-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
{% if a.answer %}<span class="ask-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
</header>
{% if a.error %}
<p class="ask-error">This ask could not be read: {{ a.error }}</p>
{% else %}
{% if a.title and not a.multi %}<p class="ask-title">{{ a.title }}</p>{% endif %}
<p class="ask-prompt">{{ a.prompt }}</p>
{% if a.answer %}
<div class="ask-answer">
{% if a.multi %}
{% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %}
<div class="ask-answer-q">
<span class="ask-answer-qprompt">{{ q.prompt }}</span>
<div class="ask-answer-choice{% if not (qa and qa.choice is not none) %} is-skipped{% endif %}">{{ qa.label if (qa and qa.choice is not none) else 'left blank' }}</div>
{% if qa and qa.notes %}<pre class="ask-answer-notes">{{ qa.notes }}</pre>{% endif %}
</div>
{% endfor %}
{% else %}
<div class="ask-answer-choice">{{ a.answer.label }}</div>
{% endif %}
{% if a.answer.notes %}<pre class="ask-answer-notes">{{ a.answer.notes }}</pre>{% endif %}
<span class="ask-answer-file">→ <a href="{{ a.stem }}.answer.json">{{ a.stem }}.answer.json</a></span>
</div>
{% endif %}
<details class="ask-formwrap"{% if not a.answer %} open{% endif %}>
<summary class="ask-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
<form class="ask-form" method="post" action="/b/{{ name_url }}/answer">
<input type="hidden" name="ask" value="{{ a.stem }}">
{# 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 %}<input type="hidden" name="back" value="asks">{% endif %}
{% for q in a.questions %}
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
<fieldset class="ask-q">
{% if a.multi %}<legend class="ask-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
<div class="ask-options">
{% for o in q.options %}
<label class="ask-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
<input type="radio" name="{{ field }}" value="{{ o.id }}"
{% if qa and qa.choice == o.id %}checked{% endif %}>
<span class="ask-opt-main">
<span class="ask-opt-label">{{ o.label }}</span>
{% if o.detail %}<span class="ask-opt-detail">{{ o.detail }}</span>{% endif %}
</span>
</label>
{% endfor %}
</div>
{% if q.notes %}
<textarea class="ask-notes ask-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
{% endif %}
</fieldset>
{% endfor %}
{% if a.notes %}
<textarea class="ask-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
{% endif %}
<div class="ask-actions">
<button type="submit" class="ask-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
</div>
</form>
</details>
{% endif %}
</article>
{% endfor %}
</section>
+135
View File
@@ -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 %}
<section class="marks">
{% for a in picks %}
<article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}">
<header class="mark-head">
<span class="mark-state">{% if a.error %}⚠ broken{% elif a.answer and a.answer.complete %}✓ answered{% elif a.answer %}◐ partial{% else %}? open{% endif %}</span>
<span class="mark-id"><code>{{ a.id }}</code>{% if a.multi %} · {{ a.questions|length }} questions{% endif %}</span>
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>{% endif %}
<span class="board-spacer"></span>
{% if a.answer and not a.answer.complete %}<span class="mark-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
{% if a.answer %}<span class="mark-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
</header>
{% if a.error %}
<p class="mark-error">This question could not be read: {{ a.error }}</p>
{% else %}
{% if a.title and not a.multi %}<p class="mark-title">{{ a.title }}</p>{% endif %}
<p class="mark-prompt">{{ a.prompt }}</p>
{% if a.answer %}
<div class="mark-answer">
{% if a.multi %}
{% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %}
<div class="mark-answer-q">
<span class="mark-answer-qprompt">{{ q.prompt }}</span>
<div class="mark-answer-choice{% if not (qa and qa.choice is not none) %} is-skipped{% endif %}">{{ qa.label if (qa and qa.choice is not none) else 'left blank' }}</div>
{% if qa and qa.notes %}<pre class="mark-answer-notes">{{ qa.notes }}</pre>{% endif %}
</div>
{% endfor %}
{% else %}
<div class="mark-answer-choice">{{ a.answer.label }}</div>
{% endif %}
{% if a.answer.notes %}<pre class="mark-answer-notes">{{ a.answer.notes }}</pre>{% endif %}
</div>
{% endif %}
<details class="mark-formwrap"{% if not a.answer %} open{% endif %}>
<summary class="mark-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
<form class="mark-form" method="post" action="/b/{{ name_url }}/answer">
{# 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. #}
<input type="hidden" name="ask" value="{{ a.id }}">
{# On the standalone page, come back HERE — the booth's own page is a
verbatim report that cannot show the recorded judgment. #}
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
{% for q in a.questions %}
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
<fieldset class="mark-q">
{% if a.multi %}<legend class="mark-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
<div class="mark-options">
{% for o in q.options %}
<label class="mark-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
<input type="radio" name="{{ field }}" value="{{ o.id }}"
{% if qa and qa.choice == o.id %}checked{% endif %}>
<span class="mark-opt-main">
<span class="mark-opt-label">{{ o.label }}</span>
{% if o.detail %}<span class="mark-opt-detail">{{ o.detail }}</span>{% endif %}
</span>
</label>
{% endfor %}
</div>
{% if q.notes %}
<textarea class="mark-notes mark-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
{% endif %}
</fieldset>
{% endfor %}
{% if a.notes_enabled %}
<textarea class="mark-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
{% endif %}
<div class="mark-actions">
<button type="submit" class="mark-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
</div>
</form>
</details>
{% endif %}
</article>
{% endfor %}
{% for a in notes %}
<article class="mark mark-note" id="mark-{{ a.id }}">
<header class="mark-head">
<span class="mark-state mark-state-note">note</span>
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
{% else %}<span class="mark-target">on this booth</span>{% endif %}
<span class="board-spacer"></span>
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
<input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this note">×</button>
</form>
</header>
<pre class="mark-text">{{ a.text }}</pre>
</article>
{% endfor %}
{% if flags %}
<article class="mark mark-flags" id="mark-flags">
<header class="mark-head">
<span class="mark-state mark-state-flag">✔ flagged</span>
<span class="mark-id">{{ flags|length }} item{{ '' if flags|length == 1 else 's' }}</span>
</header>
<ul class="mark-flaglist">
{% for a in flags %}
<li><a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></li>
{% endfor %}
</ul>
</article>
{% endif %}
{# The operator volunteering a remark, which before marks had no mechanism at
all — this is the direction that was running through chat. #}
<form class="mark-add" method="post" action="/b/{{ name_url }}/note">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea>
<button type="submit">Add note</button>
</form>
</section>
-19
View File
@@ -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. #}
<div class="boothhead">
<a class="back" href="/b/{{ name_url }}/">‹ {{ name }}</a>
<h1>Asks</h1>
{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}
<span class="sub">{% if open_asks %}<span class="badge badge-ask">{{ open_asks }} open</span> · {% endif %}{{ asks|length }} ask{{ '' if asks|length == 1 else 's' }}</span>
</div>
{% if asks %}
{% include "_asks.html" %}
{% else %}
<div class="empty">This booth has no asks.</div>
{% endif %}
{% endblock %}
+71 -50
View File
@@ -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;
+53 -6
View File
@@ -13,12 +13,50 @@
</form>
{%- endmacro %}
{# The per-item MARK controls: flag (the operator pointing at this one) and a
note field. Same macro discipline as blurtoggle above — three item branches,
one definition. `marks` here is THIS item's marks, from item_marks. #}
{% macro markcontrols(name_url, it, marks, cls='') -%}
{% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %}
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag">
<input type="hidden" name="target" value="{{ it.name }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
<button title="{{ 'un-flag this item' if flagged else 'flag this one — the session that posted it can read the selection' }}"
aria-label="{{ 'un-flag' if flagged else 'flag' }} {{ it.name }}"
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
</form>
{%- 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 <details> so 270 tiles do not each carry an open
textarea. #}
{% macro marknotes(name_url, it, marks) -%}
{% for m in marks if m.shape == 'note' %}
<div class="item-note" id="mark-{{ m.id }}">
<pre>{{ m.text }}</pre>
<form method="post" action="/b/{{ name_url }}/unmark">
<input type="hidden" name="mark" value="{{ m.id }}">
<button class="mark-x" title="withdraw this note">×</button>
</form>
</div>
{% endfor %}
<details class="item-addnote">
<summary>+ note</summary>
<form method="post" action="/b/{{ name_url }}/note">
<input type="hidden" name="target" value="{{ it.name }}">
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
<button type="submit">Add</button>
</form>
</details>
{%- endmacro %}
{% block title %}{{ name }} · The Booth{% endblock %}
{% block content %}
<div class="boothhead">
<a class="back" href="/">‹ all booths</a>
<h1>{{ name }}</h1>
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}{% if open_asks %}<span class="badge badge-ask">{{ open_asks }} open ask{{ '' if open_asks == 1 else 's' }}</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% 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 @@
</div>
{% 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 @@
</form>
{% endif %}
{% if not items and not board and not asks %}
{% if not items and not board and not marks %}
<div class="empty">This booth is empty.</div>
{% elif items %}
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
@@ -121,7 +163,7 @@
separate page. <details open> is native collapse (works with JS off);
the ✕ hides the item for the session (JS, progressive enhancement).
The item spans the full grid width so prose has room to read. #}
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}">
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.name }}">
{% 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 @@
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
{{ blurtoggle(name_url, it, 'doc-act') }}
{{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }}
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
</summary>
{% if it.rendered_html %}
@@ -147,7 +190,7 @@
</details>
</figure>
{% else %}
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}" data-item="{{ it.name }}">
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.name }}">
{% 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 @@
<figcaption>
{% if it.caption %}<span class="cap-text">{{ it.caption }}</span>{% endif %}
{{ blurtoggle(name_url, it) }}
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
</figcaption>
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
{% else %}
<figcaption>
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
<span class="cap-text">{{ it.caption or it.name }}</span>
{{ blurtoggle(name_url, it) }}
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
</figcaption>
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
{% endif %}
</figure>
{% endif %}
+5
View File
@@ -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 %}<div class="doccap">{{ caption }}</div>{% endif %}
{% if marks %}
<div class="docmarks">
{% for m in marks if m.shape == 'note' %}<pre class="vnote">{{ m.text }}</pre>{% endfor %}
</div>
{% endif %}
{% if is_html %}
<article class="markdown-body">{{ body|safe }}</article>
{% else %}
+1 -1
View File
@@ -105,7 +105,7 @@
<div class="ph">◆ files</div>
{% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
{% if b.asks_open %}<span class="badge badge-ask">? {{ b.asks_open }} ask{{ '' if b.asks_open == 1 else 's' }}</span>{% endif %}
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>{% endif %}
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
+22
View File
@@ -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. #}
<div class="boothhead">
<a class="back" href="/b/{{ name_url }}/">‹ {{ name }}</a>
<h1>Marks</h1>
{# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2).
This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which
read a half-answered pick as closed. #}
<span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }}</span>
</div>
{% if marks %}
{% include "_marks.html" %}
{% else %}
<div class="empty">This booth has no marks.</div>
{% include "_marks.html" %}
{% endif %}
{% endblock %}
+25
View File
@@ -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 %}<div class="vcap">{{ caption }}</div>{% endif %}
{# INV-3: the JUDGMENT travels to full size too, not just the caption. This is
the size at which the operator is actually deciding, so the flag toggle and
the notes belong here at least as much as on the tile. #}
<div class="vmarks">
<form class="vflag" method="post" action="/b/{{ name_url }}/flag">
<input type="hidden" name="target" value="{{ file }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
<button class="vbtn{% if flagged %} is-flagged{% endif %}"
title="{{ 'un-flag this item' if flagged else 'flag this one' }}"
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
</form>
{% for m in marks if m.shape == 'note' %}
<div class="vnote"><pre>{{ m.text }}</pre>
<form method="post" action="/b/{{ name_url }}/unmark">
<input type="hidden" name="mark" value="{{ m.id }}">
<button class="mark-x" title="withdraw this note">×</button>
</form>
</div>
{% endfor %}
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note">
<input type="hidden" name="target" value="{{ file }}">
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
<button type="submit">Add note</button>
</form>
</div>
</div>
<style>
.vnav{position:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex;