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.
113 lines
6.3 KiB
HTML
113 lines
6.3 KiB
HTML
{# Self-contained ask fragments injected into a booth's VERBATIM index.html.
|
|
|
|
The page is served untouched and carries its own CSS, so nothing here may
|
|
inherit from base.html: every fragment ships its own scoped `.bk-ask-*`
|
|
styles (emitted once, by `styles()`), and the palette adapts via
|
|
prefers-color-scheme rather than borrowing the host page's.
|
|
|
|
Per-question fragments are wired to ONE form with the HTML5 `form=`
|
|
attribute, so a four-voice report can put each radio group under its own
|
|
audio block and still submit all four picks in a single POST — which is what
|
|
the multi-question ask requires. The <form> element itself is empty and
|
|
lives with the submit block. No JavaScript.
|
|
#}
|
|
|
|
{% macro styles() %}
|
|
<style>
|
|
.bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);
|
|
border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);
|
|
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
|
.bk-ask.bk-done{border-top-color:#3fae6a}
|
|
.bk-ask.bk-skip{border-top-color:#6f7c8c}
|
|
.bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}
|
|
.bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
letter-spacing:.12em;text-transform:uppercase;color:#c9a227}
|
|
.bk-ask.bk-done .bk-ask-tag{color:#3fae6a}
|
|
.bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}
|
|
.bk-ask-prompt{margin:0 0 .6rem;font-weight:600}
|
|
.bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}
|
|
.bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;
|
|
border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}
|
|
.bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}
|
|
.bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}
|
|
.bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}
|
|
.bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}
|
|
.bk-ask-det{font-size:.8rem;opacity:.68}
|
|
.bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;
|
|
font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);
|
|
border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}
|
|
.bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;
|
|
background:#2fa8a0;color:#08131a}
|
|
.bk-ask-go:hover{filter:brightness(1.09)}
|
|
.bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}
|
|
.bk-ask-was b{opacity:1}
|
|
.bk-ask-err{color:#d6452a;font-size:.86rem}
|
|
@media (prefers-color-scheme: light){
|
|
.bk-ask-tag{color:#8a6d10}
|
|
.bk-ask-go{color:#fff}
|
|
}
|
|
@media print{.bk-ask{break-inside:avoid}}
|
|
</style>
|
|
{% endmacro %}
|
|
|
|
{# One question's radio group, bound to the shared form by id. #}
|
|
{% macro question(a, q, form_id, name_url, standalone=False) %}
|
|
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
|
{% 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.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>
|
|
{% elif skipped %}<p class="bk-ask-was">left blank — pick one any time, or leave it{% if qa and qa.notes %}; note: {{ qa.notes }}{% endif %}</p>{% endif %}
|
|
<div class="bk-ask-opts">
|
|
{% for o in q.options %}
|
|
<label class="bk-ask-opt">
|
|
<input type="radio" name="{{ field }}" value="{{ o.id }}"
|
|
{% if not standalone %}form="{{ form_id }}"{% endif %}
|
|
{% if qa and qa.choice == o.id %}checked{% endif %}>
|
|
<span class="bk-ask-lab"><span>{{ o.label }}</span>
|
|
{% if o.detail %}<span class="bk-ask-det">{{ o.detail }}</span>{% endif %}</span>
|
|
</label>
|
|
{% endfor %}
|
|
</div>
|
|
{% if q.notes %}
|
|
<textarea class="bk-ask-notes" name="notes.{{ q.key }}" rows="2"
|
|
{% if not standalone %}form="{{ form_id }}"{% endif %}
|
|
placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
|
{% endif %}
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
{# 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.id }}-submit">
|
|
<form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
|
|
<input type="hidden" name="ask" value="{{ a.id }}" form="{{ form_id }}">
|
|
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered {{ a.answer.answered_at }}
|
|
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · {{ a.answer.answered_at }}
|
|
{%- else %}? submit your picks{% endif %}</span>
|
|
{% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
|
|
{% if a.notes_enabled %}
|
|
<textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}"
|
|
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
|
{% endif %}
|
|
<button type="submit" class="bk-ask-go" form="{{ form_id }}">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
{# The whole ask as one self-contained block: title, every question, submit. #}
|
|
{% 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">this question could not be read: {{ a.error }}</p></div>
|
|
{% else %}
|
|
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.id }}">{{ a.title }}</p>{% endif %}
|
|
{% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %}
|
|
{{ submit(a, form_id, name_url) }}
|
|
{% endif %}
|
|
{% endmacro %}
|