diff --git a/services/booth/README.md b/services/booth/README.md index 302b163..3eb693e 100644 --- a/services/booth/README.md +++ b/services/booth/README.md @@ -161,12 +161,32 @@ Rules of the primitive: - **A broken ask is shown as broken**, not hidden: if the JSON does not validate, the page says why, so a session never thinks it posted a question the operator cannot see. -- **A booth with its own `index.html` shows a chip, not the panel.** That page is - served verbatim by design, so the inline panel cannot appear on it: the Booth - injects an amber `? N open asks` chip (next to the back chip) linking to - **`/b//asks`**, a standalone page carrying the real forms. Answering - there returns there. Put the media in the booth and the ask beside it either - way — the ask is never lost, whichever shape the booth takes. +- **A booth with its own `index.html` gets the ask INLINE, where you put it.** + That page is served verbatim, so the Booth substitutes placeholders in your + markup rather than rendering the panel above a gallery it does not have: + + ```html +
+
+
+ + ``` + + Per-question fragments bind to **one** form with the HTML5 `form=` attribute, + so a four-voice audition can put each radio group under that voice's audio and + still submit all four picks in a single POST — which is what a multi-question + ask requires. The fragments ship their own scoped styles and inherit nothing + from your page. No JavaScript. + + ⚠ Put the placeholder **outside** any CSS grid or flex container, or it + becomes a cell in it. A sibling of the block it belongs to is right. + + Placement is optional: a page with no placeholders gets the whole ask appended + at the end, so an ask is never invisible — markup only moves it somewhere + better. Mark up some questions and not others and the rest are appended too, + because a multi-question form that is missing a question is a 400 the operator + would only meet after filling it in. The amber chip stays as a jump link to the + first open ask, and `/b//asks` still renders every ask on its own page. - Ask/answer files are not gallery items and do not count toward the booth's item count; they render as the panel above the gallery. Answering bumps the booth's mtime, so it lives another TTL — the session has 24h to read it. diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py index aa508b6..435081c 100644 --- a/services/booth/booth/app.py +++ b/services/booth/booth/app.py @@ -86,6 +86,11 @@ from booth.asks import ( # noqa: E402 valid_stem, write_answer, ) +from booth.inline import ( # noqa: E402 + form_id as ask_form_id, + has_placeholders, + place as place_asks, +) from booth.links import ( # noqa: E402 LINK_LOCK, LINKS_FILE, @@ -399,12 +404,13 @@ _BACK_CHIP = ( # say so (found 2026-09-09 on `emmie-anchor`: valid ask, CLI listed it, page # showed nothing). Same injection mechanism as the back chip; it links to the # standalone /asks page, which renders the real forms. -def asks_chip(name: str, open_count: int) -> str: +def asks_chip(name: str, open_count: int, href: str | None = None) -> str: if open_count < 1: return "" label = f"? {open_count} open ask" + ("" if open_count == 1 else "s") + href = href or f"/b/{quote(name, safe='')}/asks" return ( - f'{label}' + f'{label}' " +{% 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 %} +
+ {% if qa %}✓ answered{% else %}? your pick{% endif %} +

{{ q.prompt }}

+ {% if qa %}

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

{% endif %} +
+ {% for o in q.options %} + + {% endfor %} +
+ {% if q.notes %} + + {% endif %} +
+{% endmacro %} + +{# The form element + hidden fields + overall notes + submit. Empty
on + purpose: the question groups above bind to it by id from wherever they sit. #} +{% macro submit(a, form_id, name_url) %} +
+ + + {% if a.answer %}✓ answered {{ a.answer.answered_at }}{% else %}? submit your picks{% endif %} + {% if a.notes %} + + {% endif %} + +
+{% endmacro %} + +{# The whole ask as one self-contained block: title, every question, submit. #} +{% macro whole(a, form_id, name_url) %} +{% if a.error %} +
⚠ broken ask +

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

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

{{ a.title }}

{% endif %} + {% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %} + {{ submit(a, form_id, name_url) }} +{% endif %} +{% endmacro %} diff --git a/services/booth/pyproject.toml b/services/booth/pyproject.toml index 009e053..99812da 100644 --- a/services/booth/pyproject.toml +++ b/services/booth/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "booth" -version = "0.1.12" +version = "0.1.14" description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator." requires-python = ">=3.11" dependencies = [ diff --git a/services/booth/tests/test_asks.py b/services/booth/tests/test_asks.py index 3986466..363ba4e 100644 --- a/services/booth/tests/test_asks.py +++ b/services/booth/tests/test_asks.py @@ -318,15 +318,17 @@ def test_write_ask_accepts_full_doc(tmp_path): # into the verbatim page plus a standalone /asks page that carries the forms. -def test_verbatim_booth_gets_an_asks_chip(client): +def test_verbatim_booth_renders_the_ask_inline(client): c, data = client b = _ask(data / "b") (b / "index.html").write_text("reporthi") html = c.get("/b/b/").text - assert "hi" in html # the report is still served verbatim - assert "booth-nav-asks" in html # ...with a chip pointing at the asks - assert "1 open ask" in html - assert "/b/b/asks" in html + assert "hi" in html # the report is still served verbatim + assert "Which render wins?" in html # ...with the ask ON it, not elsewhere + assert 'type="radio"' in html and 'action="/b/b/answer"' in html + assert "bk-ask" in html # self-contained fragment styles + assert "booth-nav-asks" in html # chip remains, as a jump link + assert "#bk-ask-winner-top" in html def test_verbatim_chip_disappears_once_answered(client): @@ -381,3 +383,87 @@ def test_asks_page_shows_a_single_ask_title(client): c, data = client _ask(data / "b", title="emmie — pick the anchor") assert "emmie — pick the anchor" in c.get("/b/b/asks").text + + +# ---- inline placement in a verbatim report ----------------------------------- +# +# Operator verdict 2026-09-09 on the separate /asks page: "the asks should be +# inline with the artifacts, not on a separate page." A four-voice audition wants +# each voice's radio group under that voice's audio, and one submit for the lot. + + +REPORT = """audition +

Three voices

+
+
+
+
+
+""" + + +def test_per_question_placeholders_land_where_the_author_put_them(client): + c, data = client + b = _multi(data / "b") + (b / "index.html").write_text(REPORT) + html = c.get("/b/b/").text + # each group is inside its own section, in document order + lawson = html.index('id="lawson"') + jo = html.index('id="jo"') + assert lawson < html.index('name="choice.r1"') < jo + assert jo < html.index('name="choice.r2"') + # one shared form, bound by the HTML5 form= attribute, submitted once + assert html.count('
= 4 + # the submit block landed at its own placeholder, not appended after + assert html.index("bk-ask-form-batch") < html.index("") + + +def test_inline_form_submits_every_question_in_one_post(client): + c, data = client + b = _multi(data / "b") + (b / "index.html").write_text(REPORT) + r = c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep", "notes.r1": "crisp", + "choice.r2": "d", "notes": "ship r1"}, follow_redirects=False) + assert r.status_code == 303 + ans = read_answer(b, "batch") + assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d" + # and the recorded pick now shows inline, on the report itself + html = c.get("/b/b/").text + assert "recorded:" in html and "bk-done" in html + assert 'value="keep" required checked' in html.replace("\n", " ") or "checked" in html + + +def test_whole_ask_placeholder_renders_everything_there(client): + c, data = client + b = _ask(data / "b") + (b / "index.html").write_text('

x

') + html = c.get("/b/b/").text + assert html.index("Which render wins?") > html.index("

x

") + assert html.index("bk-ask-go") < html.index("") # submit placed inline too + + +def test_placeholder_for_a_missing_ask_is_left_alone(client): + c, data = client + b = _ask(data / "b") + (b / "index.html").write_text('
') + html = c.get("/b/b/").text + assert 'data-booth-ask="typo"' in html # author's markup untouched, not blanked + assert "Which render wins?" in html # the real ask still appended, never lost + + +def test_questions_placed_without_a_submit_still_get_one(client): + c, data = client + b = _multi(data / "b") + (b / "index.html").write_text('
') + html = c.get("/b/b/").text + assert html.count(' must still appear + + +def test_styles_are_emitted_once(client): + c, data = client + b = _multi(data / "b") + (b / "index.html").write_text(REPORT) + assert c.get("/b/b/").text.count(".bk-ask-opt:has(input:checked)") == 1