Files
booth/booth/templates/_ask_inline.html
T
vh 87e2c5364c feat(u3): a verbatim report declares the seam, the Booth mounts into it
A booth that ships its own index.html was served through ten regular
expressions applied to markup the Booth did not write: six in
wrap_verbatim_html hunting for somewhere to hang a favicon and a chip, four
in booth/inline.py substituting rendered ask markup into the author's own
tags. Both worked. Both were the most fragile thing in the service, on the
path the operator uses most.

The whole class is replaced by a declared seam. A report carries one line —
<script src="/_booth/embed.js" defer></script> — and the chrome mounts
through DOM APIs. What the server does to author HTML is now, in full:

    return html if declares_embed(html) else html + EMBED_SCRIPT_TAG

Two substring tests and a concatenation. Both of the old wrapper's hard
constraints stop existing rather than being satisfied more carefully:
nothing can displace a leading doctype into quirks mode and nothing can push
the charset meta out of its detection window, because nothing in front of
them ever moves. A page that declares the seam is served exactly as written.

Fragments are still rendered by the _ask_inline.html macros and handed over
GET /b/<name>/embed.json; embed.js places them and decides nothing. Openness
comes from open_marks, order from (created, id), questions in declaration
order. A single-question pick normalizes to key None, so the payload carries
questions as a list rather than an object — keying by name would serialize
that as the string "null".

Placement is an anchor fill, not a replacement: el.insertAdjacentHTML(
'beforeend'), so an author's wrapper and its contents survive. The regex it
replaces was eating the opening tag of dfa-concepts' styled .ask blocks and
orphaning their headings, live, unreported.

data-booth-mark is canonical; data-booth-ask stays a kept alias because two
live reports use it. The comment placeholders are dropped — no users.

Declared cost: the verbatim path now needs JavaScript. The never-invisible
guarantee holds through the index badge and /b/<name>/marks, both of which
render server-side.

Deleted: booth/inline.py entire, wrap_verbatim_html and its six patterns,
_BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK, the styles() macro.

Tests 410 -> 434. tests/test_embed_browser.py drives a real Chromium: the
placement algorithm and the form= binding of a scattered multi-question form
cannot be observed any other way, and that binding was measured rather than
assumed (N=3 per condition, with a form-first positive control and a
points-at-nothing negative control).

Contract: docs/contracts/u3_declared_embed_seam.contract.md, with the
in-session seam review and the cold contract panel both recorded. Two of the
panel's findings were code fixes: a vacuous INV-3 falsifier that a renamed
regex walked straight through, and a bare-substring seam detection that read
a report merely quoting the path as declaring it and silently served it with
no chrome.
2026-09-22 10:43:41 -07:00

79 lines
4.4 KiB
HTML

{# Self-contained ask fragments for a booth's VERBATIM index.html.
The page is served untouched and carries its own CSS, so nothing here may
inherit from base.html. Since U3 these fragments do not reach the page by
string substitution: they are rendered here, handed over
`/b/<name>/embed.json`, and MOUNTED INTO THE DOM by `/_booth/embed.js`. The
scoped `.bk-ask-*` styles live in that file alongside the code that needs
them, which is why this template no longer emits a `styles()` block.
These macros stay the ONE renderer of an ask fragment. embed.js places what
comes back and never builds one.
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.
#}
{# 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 %}