Files
booth/docs/contracts/u1_item_record.contract.md
T
vh ce598b3cf6 feat(items): one item record, so an annotation survives the zoom
The operator reported that zoomed-in images lose their annotations. That was
never a rendering bug. Three functions independently walked a booth and derived
overlapping subsets of the same facts -- build_gallery (kind, caption, blur,
doc), booth_view_file (kind, doc, image ring) and list_booths (kind counts,
cover) -- and the zoom route's subset was the smallest. Caption resolution lived
inside build_gallery's loop and nowhere else, so there was no code path by which
a caption could reach the zoom template. It was never sent.

booth/items.py is now the one truth: booth_items() returns the full record --
rel, kind, section, caption, blur, doc kind, size -- and the gallery, the zoom
view, the doc view and the index all read it. Patching view.html would have
fixed the symptom for images and left the next surface starting from the same
missing truth.

Two things fall out of the consolidation:
  - the index and the booth page now agree on what an item IS. list_booths
    counted every non-dot file, so an A/B pair with two caption sidecars read
    as 4 items on the index and showed 2 tiles when you opened it.
  - "section" (the item's subfolder) is computed and carried but nothing renders
    it yet. That is deliberate: it is U7's whole input, and shipping the field
    now makes U7 a template change rather than a resolver change.

Doc bodies are NOT rendered by the resolver -- the index touches every booth on
every page load, and rendering every markdown file in every booth would be the
price of that convenience. render_doc_body is a separate step for the one
surface that inlines them; an invariant test monkeypatches it to raise and
loads the index.

Verified beyond the suite, because this repo has shipped two dead controls that
every test passed: the caption was measured in a real browser at 1280x41 px,
visible, with elementFromPoint at its centre returning the caption itself.
layout-probe reports all controls hittable across index, gallery, zoom and doc.

192 tests pass (173 before, 19 new).

Contract: docs/contracts/u1_item_record.contract.md
2026-09-21 22:04:07 -07:00

10 KiB

contract_version, module, purpose, depends_on, language, complexity, estimated_loc, confidence, used_by, touches, assumptions, open_questions
contract_version module purpose depends_on language complexity estimated_loc confidence used_by touches assumptions open_questions
1.0 booth.items ONE resolver for what is in a booth. `booth_items(booth)` returns the full record for every renderable file -- relative path, kind, section, caption, blur state, doc kind and size -- and the gallery, the zoom view, the doc view, the index thumbnail and the image prev/next chain all read from it. Today three independent paths (`build_gallery`, `booth_view_file`, `list_booths`) re-derive overlapping subsets of the same facts from the filesystem, and the zoom path derives a strictly smaller subset: it never resolves the caption at all, so an annotated image loses its annotation the moment it is opened full-size. That is not a rendering bug to patch in one template; it is three readers of one truth, and the fix is to have one.
booth.asks (is_ask_file, is_answer_file -- the two sidecar shapes excluded from items; unchanged by this unit, folded into marks at U2)
python low 180 0.9
booth.app.build_gallery (becomes a thin adapter over booth_items + render_doc_body)
booth.app.booth_view_file (the zoom and doc routes -- gains caption/section/blur it never had)
booth.app.list_booths (index counts + cover thumbnail: ONE classification path instead of a second inline loop)
booth.app.booth_image_names (deleted -- becomes a filter over the item list)
booth/items.py (new)
booth/app.py (build_gallery reduced to an adapter; booth_view_file reads the record; list_booths uses the resolver; booth_image_names removed; classify/doc_kind/CAPTION_MAX/IMAGE_EXTS/VIDEO_EXTS/AUDIO_EXTS/MARKDOWN_EXTS/TEXT_EXTS/DOC_MAX_BYTES move to items.py and are re-exported so existing imports keep working)
booth/templates/view.html (renders the caption, section and blur state it is now given)
booth/templates/doc.html (same)
tests/test_items.py (new)
tests/test_booth.py (existing build_gallery/classify/doc_kind tests keep passing through the re-exports; new assertions that the zoom route carries the caption)
RE-EXPORT, NOT MOVE-AND-BREAK. `classify`, `doc_kind`, `render_doc`, `CAPTION_MAX` and the extension sets are imported by name from `booth.app` in 20+ existing test sites. They move to `booth.items` and `booth.app` re-exports them, so no test is edited to chase an import. The re-export is load-bearing and is asserted by a test, not left to convention -- a silent drop would be found by a consumer, not by us.
DOC BODIES ARE NOT RENDERED BY THE RESOLVER. `build_gallery` today eagerly renders every markdown/text file under DOC_MAX_BYTES. If the resolver did that, the INDEX -- which calls it once per booth to count items and pick a cover -- would markdown-render every doc in every booth on every page load. So the record carries `doc` (the kind) and `size`, and a separate `render_doc_body(booth, item)` does the work for the one consumer that needs it. Same rendering, same DOC_MAX_BYTES bound, same raw-text-for-<pre> contract; strictly less work on the index.
CAPTION RESOLUTION IS PRESERVED EXACTLY, not re-specified. Two forms, in this precedence: (1) `<file>.txt` -- `a.png.txt` captions `a.png`; (2) `<stem>.txt` beside a same-stem non-`other` sibling -- `a.txt` captions `a.png`, but NOT `a.bin`. A file consumed as a caption is excluded from the item list. Truncated at CAPTION_MAX (800). This is existing behaviour with existing tests; the unit moves it, it does not improve it. Any change here is a separate unit.
SECTION is the item's parent directory relative to the booth, or None at the root. Purely derived -- no new state, no config. It is the navigation fix's whole input (U7) and it already exists on disk: `pewpew-ui-brief` has integration/ and blueprint/, `dfa-concepts` has source/. This unit computes and carries it; NOTHING renders it yet. That is deliberate -- U1 is the record, U7 is the view, and shipping the field early means U7 is a template change rather than a resolver change.
ORDER is `sorted(rel)` as today -- byte order over the POSIX relative path, which groups a subfolder's items together as a side effect. U7 may impose a section-aware order; until then the gallery renders in exactly the order it renders in now, so this unit is not allowed to move a single tile.
BLUR state is read once per resolver call, not once per item. `read_blurred` opens `.blurred` on every call; `build_gallery` already hoists it, `list_booths` does NOT (it calls read_blurred inside a conditional per booth). One read per call, passed down.
Whether `other`-kind files should carry a section header in U7 or stay in a trailing 'files' group -- a view question, deferred with U7.
Whether CAPTION_MAX should grow now that a caption also renders at full size in the zoom view, where there is room for it. Left at 800; changing it is a one-line follow-up with no structural consequence.

U1 — the item record

The defect, stated precisely

Three functions independently walk a booth and derive facts about its files:

reader derives omits
build_gallery (app.py:314) kind, caption, blur, doc kind, rendered body section
booth_view_file (app.py:857) kind, doc kind, rendered body, image prev/next caption, blur, section
list_booths (app.py:265) kind (counts), cover thumb, cover blur everything else

booth_view_file is the zoom and doc route. It calls classify(target.name), booth_image_names(booth), doc_kind(target.name) and render_doc(...) — it re-derives the item from scratch and, because caption resolution lives inside build_gallery's loop and nowhere else, there is no code path by which a caption could reach the zoom template. The operator's report that "zoomed-in images lose their annotations" is exact, and the annotation is not lost in rendering: it is never sent.

Patching view.html fixes the symptom for images. The next template — the doc view, the compare view, the zip manifest — starts from the same missing truth.

The record

@dataclass(frozen=True)
class Item:
    rel: str            # booth-relative POSIX path; the item's identity
    url: str            # quote(rel, safe="/")
    kind: str           # "image" | "video" | "audio" | "other"
    section: str | None # parent dir relative to the booth; None at the root
    caption: str | None # resolved sidecar text, <= CAPTION_MAX
    blurred: bool
    doc: str | None     # "markdown" | "text" | None
    size: int           # bytes; lets a consumer decide about DOC_MAX_BYTES

Signatures

def booth_items(booth: Path) -> list[Item]:
    """Every renderable file in a booth, sorted by rel.

    Excluded: dotfiles, `*.ask.json` / `*.answer.json` (they render as the asks
    panel, not as tiles), and any file consumed as another item's caption.
    """

def image_chain(items: Sequence[Item]) -> list[str]:
    """The rels of the image items, in order -- the zoom view's prev/next ring.
    Replaces booth_image_names, which walked the tree a second time."""

def find_item(items: Sequence[Item], rel: str) -> Item | None:
    """The record for one rel, or None. The zoom/doc route's entry point."""

def render_doc_body(booth: Path, item: Item) -> tuple[str, bool] | None:
    """(body, is_html) for a doc item under DOC_MAX_BYTES, else None.
    Markdown -> HTML (marked safe by the caller); text -> RAW, escaped by the
    template inside <pre>. Pre-escaping here double-encodes under autoescape."""

Invariants

  • INV-1 — one truth. No consumer classifies a file, resolves a caption, or reads blur state on its own. Every fact about an item comes from its Item. Falsifiable: no call to classify, doc_kind or read_blurred remains in a route body.
  • INV-2 — the caption travels. If booth_items resolves a caption for an item, every surface that renders that item renders the caption: gallery, zoom, doc view. Falsifiable: fetch /b/<n>/view?f=<img> for an item with a sidecar and assert the caption text is in the served HTML.
  • INV-3 — order is unchanged. For every booth, the rels from booth_items equal, in order, the names from today's build_gallery. This unit reorganises who computes what; it does not move a tile.
  • INV-4 — the index does not render docs. list_booths completes without calling render_doc_body. Falsifiable: monkeypatch it to raise, load /.
  • INV-5 — re-exports hold. classify, doc_kind, render_doc, CAPTION_MAX and the extension sets remain importable from booth.app.

Steps

  1. booth/items.py: the extension sets, classify, doc_kind, render_doc, CAPTION_MAX, DOC_MAX_BYTES, the Item dataclass, booth_items, image_chain, find_item, render_doc_body. Caption resolution moves verbatim out of build_gallery.
  2. booth/app.py: re-export the moved names. build_gallery becomes an adapter returning today's dict shape (templates unchanged in this unit) built from booth_items + render_doc_body. Delete booth_image_names.
  3. booth_view_file: resolve through find_item; pass caption, section, blurred, and image_chain for prev/next.
  4. list_booths: counts and cover from booth_items; one read_blurred.
  5. view.html / doc.html: render the caption and blur state they now receive.

Tests

test asserts
zoom_carries_the_caption /b/<n>/view?f=a.png with a.png.txt present contains the caption text — the operator-reported bug, as a regression test
zoom_carries_the_stem_caption the a.txt-beside-a.png form too
doc_view_carries_the_caption same for /view?f=notes.md
section_is_the_parent_dir v3/x.png → "v3"; x.png → None
order_matches_todays_gallery INV-3, over a fixture with subfolders, sidecars and mixed kinds
caption_sidecars_are_not_items both forms excluded, as today
a_txt_beside_a_bin_is_its_own_item the classify != "other" guard survives
index_renders_no_doc_bodies INV-4 via monkeypatch
app_still_exports_the_moved_names INV-5 — each name importable from booth.app
image_chain_matches_booth_image_names the deleted helper's output, reproduced
ask_sidecars_are_not_items *.ask.json / *.answer.json still excluded