--- contract_version: "1.0" module: "booth.items" purpose: "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." depends_on: - "booth.asks (is_ask_file, is_answer_file -- the two sidecar shapes excluded from items; unchanged by this unit, folded into marks at U2)" language: "python" complexity: "low" estimated_loc: 180 confidence: 0.9 used_by: - "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)" touches: - "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)" assumptions: - "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-
contract; strictly less work on the index." - "CAPTION RESOLUTION IS PRESERVED EXACTLY, not re-specified. Two forms, in this precedence: (1) `.txt` -- `a.png.txt` captions `a.png`; (2) ` .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." open_questions: - "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 ```python @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 ```python 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-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//view?f= ` 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/
/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 |