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
This commit is contained in:
vh
2026-09-21 22:04:07 -07:00
parent 726822b2b9
commit ce598b3cf6
6 changed files with 773 additions and 172 deletions
+142
View File
@@ -0,0 +1,142 @@
---
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-<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."
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>. 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 |