--- 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 |