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:
+215
-75
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user