--- contract_version: "0.1-PROPOSED" status: "LANDED 2026-09-22, all four components. The operator ratified the scope departure (drop subfolder sections, add filename-prefix groups) and settled the `unanswered` open question in favour of the shipped reading. Rail, filters and grid keyboard landed at a306e2d; the groups landed in the commit carrying this revision, which also DELETED tests/test_navigation.py::test_no_group_rail_is_shipped_yet — the guard that held the departure back while the ruling was outstanding. ⚠ TWO THINGS IN THIS CONTRACT CHANGED AT IMPLEMENTATION, both measured rather than preferred: the grouping RULE (see Signatures) and INV-3, which guarded one degeneracy and needed to guard two. The original text of both is kept below, struck, because the reasoning is the useful part." module: "booth.items + booth.app (gallery navigation)" purpose: "The last unit before the 1.0 cut. A gallery booth renders as one flat wall with no way to filter it, no way to move through it from the keyboard, and no grouping — so a review of sixty-odd renders is a scroll-and-squint. ROADMAP names four components: sections, a sticky rail, filters, grid keyboard. THE MEASUREMENT KILLS THE FIRST AND REPLACES IT: not one of the eleven live gallery booths has a subdirectory, so sections buy nothing, while a filename-prefix heuristic yields 5-16 sensible groups on four of the five large galleries. This unit ships the rail, the filters, the grid keyboard, and GROUPS DERIVED FROM FILENAMES rather than from a directory tree that does not exist." depends_on: - "booth.items.booth_items (INV-1: one resolver for item facts. `Item` gains ONE field, `group`, derived here and nowhere else. No route body derives it, exactly as no route body derives `section`, `caption` or `blurred`.)" - "booth.items.Item.section (ALREADY EXISTS from U1 and STAYS. This unit does not delete it and does not render a rail from it — those are different questions. A booth that does have subdirectories keeps its section values; nothing regresses.)" - "booth.app.build_gallery (the thin adapter over `booth_items`; it shapes items for the template and is where `group` reaches the page)" - "booth.app.image_chain (the zoom prev/next ring. UNCHANGED, and named here because it was CHECKED: the ring is the item order filtered to images, and grouping must not reorder it -- a filter that changed what `next` means would misfile the operator's judgment, which is CLAUDE.md invariant 6's whole reason for existing.)" language: "python + jinja + a little javascript" complexity: "medium" estimated_loc: 300 confidence: 0.6 used_by: - "booth.app.booth_view (the gallery page gains a rail and a filter state; the grid gains keyboard focus)" touches: - "booth/items.py (the `group` field and its derivation)" - "booth/app.py (build_gallery carries `group`; booth_view passes group counts)" - "booth/templates/booth.html (the rail, the filter controls, the grid's focus affordances)" - "booth/templates/base.html (rail + focus CSS)" - "booth/static/embed.js (NOT TOUCHED — named because it was checked; the verbatim path has no grid)" - "tests/test_items.py (group derivation)" - "tests/test_navigation.py (new — rail, filters, keyboard)" - "ROADMAP.md (the deterministic-order table gains the group row; U7's row is rewritten)" assumptions: - "THE SCOPE DEPARTURE WAS RATIFIED BY THE OPERATOR 2026-09-22. ROADMAP's U7 row said `sections, rail, filters, grid keyboard`; this contract drops sections and adds filename groups. The evidence is in `persistent-memory.d/2026-09-22-u7-remeasured-before-scoping.md`: zero of eleven gallery booths have a subdirectory, the only two booths that do are reports, and `pewpew-ui-brief`'s seven subdirectories hold one image between them." - "THE GROUP HEURISTIC DEGENERATES IN TWO DIRECTIONS, NOT ONE, AND THIS CONTRACT ORIGINALLY SAW ONLY THE FIRST. (a) ONE GROUP FOR EVERYTHING -- live specimen `sc-iso-spread`, `DSC0001.jpg` through `DSC0006.jpg`. (b) ONE GROUP PER ITEM -- live specimens `pewpew-ui-brief` at 23 groups for 34 items and `dfa-concepts` at 13 for 20. Both render as NO rail, because a navigation affordance that cannot navigate is worse than none: it occupies the space where the real one would be. Degeneracy (b) is the one the shipped rule actually meets on the live set, and the contract as first written would have shipped it everywhere." - "GROUPING IS A VIEW, NEVER A REORDERING. The item order stays `sorted(rel)` (U1 INV-3) and the zoom ring stays that order filtered to images. Grouping and filtering change what is SHOWN and never the sequence -- so `the third one` means the same thing with a filter on as with it off, and a flag lands where the operator thinks it does. This is the whole of CLAUDE.md invariant 6 applied to a surface that did not exist when it was written." - "THE PAGE WORKS WITH NO JAVASCRIPT. Filters are links with a query parameter, resolved server-side; the rail is anchors. Keyboard is the one genuinely JS-only affordance and it is additive -- the page is fully usable without it. U3 cost the verbatim path its no-JS operation and said so plainly; this unit must not quietly do the same to the gallery, which is the surface the operator actually reviews on." - "VIRTUALIZATION STAYS PARKED. The largest gallery is 66 images. ROADMAP parks progressive loading with `measure the real booth before optimising it`; at this size a lazy grid is almost certainly fine, and inventing the work is the failure the parking lot exists to prevent." open_questions: - "WHETHER THE GROUP HEURISTIC SHOULD BE OVERRIDABLE. A booth could carry a `.groups` dotfile naming its own grouping, the way `.blurred` names blur. Not designed here: no live booth wants it, the heuristic is right on four of five, and adding an override before anyone has been failed by the default is speculative. Parked, not solved." - "RESOLVED 2026-09-22 — `unanswered` means `has an open pick`, the U4 hold predicate, which is what shipped. The `has no mark at all` reading is a genuinely different question and is PARKED for v1.1 rather than pending." --- # U7 — navigation at the size the booths actually are **LANDED — all four components.** | component | ROADMAP says | state | |---|---|---| | sticky rail | ratified | **landed** — totals + per-filter counts, links not scripts | | filters | ratified | **landed** — all / flagged / annotated / unanswered | | grid keyboard | ratified | **landed** — `←/→ f n Enter Esc`, bound only when a grid exists | | **sections → filename groups** | **departs from it** | **landed** — ratified by the operator 2026-09-22. `test_no_group_rail_is_shipped_yet`, the guard that held it back, was deleted in the same commit that built it. | `unanswered` means **has an open pick** — the U4 hold predicate. **Settled by the operator 2026-09-22**; the "has no mark at all" reading is a different question and is parked, not pending. ## The defect, re-measured rather than inherited ROADMAP sizes this unit for 270 items. **The largest gallery is now 81 items and 40 images.** The four booths it was written against were swept on 2026-09-22 and the set churned again during that session. The defect is real and the sizing is not: | | ROADMAP's premise | measured 2026-09-22 | |---|---|---| | largest gallery | 270 images, one flat wall | **`sindra-bakeoff`, 40 images** | | galleries with subdirectories | "sections come from subfolders, which already exist" | **0 of 11** | | booths with subdirectories at all | — | 2, and **both are reports** | | grouping signal that does exist | — | **the filename prefix** | ## Sections are dead. The prefix is not. ⚠ **THE TABLE BELOW IS THE RE-MEASUREMENT, AND IT DISAGREES WITH THE ONE THIS CONTRACT WAS WRITTEN ON.** The original claimed the rule `strip ONE trailing run of digits` produced **5** groups on `sindra-bakeoff` and **1** on `sindra`. Neither reproduces: that rule gives **24** and **27**. The original table's own worked example says so out loud — it notes `00-sheet-c1-market-noon.png` has no trailing digit run and therefore groups as its whole stem, which makes eight of bakeoff's forty images eight singleton groups. **The numbers 5 and 1 are reproducible only by two OTHER rules** (first-two-segments gives exactly 5 on bakeoff; first-segment gives exactly 1 on sindra), so the table that justified this design was assembled from more than one heuristic. Caught by implementing the stated rule and running it against the live set rather than trusting the table beside it. **The shipped rule** — first separator-delimited segment, destemmed only when the stem has no separator — measured against all 17 live booths, 2026-09-22. `G` is groups, `med` the middle group's size, `sing` the singleton groups: | booth | items | G | med | sing | rail? | |---|---|---|---|---|---| | `sindra-corpus-v1` | 66 | 11 | 5 | 4 | **yes** — `ac 12 · bu 10 · cu 12 · fb 12 · … · wu 8` | | `sindra-sfw-pool` | 59 | 6 | 11 | 0 | **yes** | | `sindra-nude-pool` | 42 | 9 | 4 | 1 | **yes** | | `sindra-bakeoff` | 41 | 4 | 12 | 1 | **yes** — `00 · README · m · r`, the three real families | | `sindra` | 31 | 2 | 15 | 1 | **yes** | | `muse-clothed-repro` | 7 | 3 | 2 | 1 | **yes** — `v30`/`v35`, the axis that booth is about | | `pewpew-ui-brief` | 34 | 23 | 1 | 19 | no — **degeneracy (b)** | | `dfa-concepts` | 20 | 13 | 1 | 8 | no — **degeneracy (b)** | | `cr123a-to-d-sleeve` | 7 | 6 | 1 | 5 | no — degeneracy (b) | | `sc-iso-spread` | 6 | 1 | 6 | 0 | no — **degeneracy (a)**, `DSC0001`–`DSC0006` | | `music3-songs`, `krea2-lora-portability` | 3 | 1 | 3 | 0 | no — degeneracy (a) | | `miranda-is`, `sindra-voice-1` | 47 / 10 | 10 / 6 | 2 / 2 | 3 / 2 | **no grid at all** — both carry `index.html` and take the verbatim path | **Why the rule changed.** `strip ONE trailing run of digits` keys on the END of the stem, which is where the *instance number* lives — so it separates `m-c1-market-noon-9401` from `m-c2-rain-street-9403`, which are the same family. The shipped rule keys on the START, which is where the *family* lives. The competing heuristics measured and rejected: split-on-second-hyphen (59 groups from 59 files), and destemming the first segment unconditionally (merges `v30` with `v35`). **The honest cost.** Destemming a flat stem is what makes `ac01.png` → `ac` work, and it is exactly what would merge `v30` with `v35` if applied to a segmented name. The rule therefore has a conditional in it, which is one more thing than "take the first segment" — paid because `sindra-corpus-v1`, the largest gallery, is entirely flat names. ## What ships 1. **`Item.group`** — derived once, in the resolver, beside `section`. 2. **A sticky rail** — total, per-group counts, per-filter counts, jump-to-group anchors. **Absent entirely when there is one group or fewer.** 3. **Filters** — all / flagged / annotated / unanswered, as server-resolved query parameters so they work with JS off. 4. **Grid keyboard** — `←/→` move focus, `f` flags, `n` opens a note, `Enter` zooms, `Esc` clears focus. Additive; the page is complete without it. ## Signatures ```python def _group_of(rel: str) -> str | None: """The grouping key for an item, or None when it has none. THE RULE, in one line: the first separator-delimited segment of the basename's stem -- with a trailing digit run stripped only when the stem has no separator at all. 00-sheet-c1-market-noon.png -> 00 m-c1-market-noon-9401.png -> m flag-rear.png -> flag ac01.png -> ac (no separator: the digits ARE it) DSC0001.jpg -> DSC v30-seed8302.png -> v30 (separator present, so v30 != v35) 01.png -> None (nothing before the digits) Derived HERE and nowhere else (INV-1). """ ``` ~~**SUPERSEDED — the rule this contract was written with.**~~ *"take the stem of the basename, strip ONE trailing run of digits and any single separator before it. `ac01.png` → `ac`; `00-sheet-c1-market-noon.png` → `00-sheet-c1-market-noon` (no trailing digit run, so the whole stem); `flag-rear.png` → `flag-rear`."* Kept struck rather than deleted: it is the rule the measurement table above was supposed to describe, and the mismatch between the two is the thing worth remembering. It keys on the end of the stem, where the instance number lives, and so splits families rather than gathering them. ## Ordering — the rule, because invariant 6 binds | collection | rule | |---|---| | items | **unchanged** — `sorted(rel)` (U1 INV-3) | | the zoom ring | **unchanged** — item order filtered to images | | **groups among themselves** | **the position of each group's FIRST member in the RENDERED sequence** — which is `sorted(rel)` narrowed by the filter and never re-sorted. So the rail reads in the same direction the grid does, and adding a file never reshuffles the rail unless it lands first in its group. Implemented by walking `shown` once into an insertion-ordered `dict`: the walk IS the rule, so there is no second sort to drift from it. | | items within a group | **unchanged** — they are a filtered view of `sorted(rel)`, never re-sorted | | the filtered grid | **unchanged** — `sorted(rel)` with non-matching items hidden | This closes ROADMAP's outstanding U7 order question. Compare pairing is not this unit's problem — compare mode is parked to v1.1 with the pairing rule. ## Invariants **INV-1 — one resolver derives the group.** `_group_of` is called only from `booth_items`. *Falsifiable:* the defeating change is a route or template computing a prefix inline. The test asserts no call to `_group_of` survives inside `create_app` — the same assertion U1 makes for `classify` and `render_doc`, which is why it is the shape used here. **INV-2 — grouping and filtering never reorder.** *Falsifiable:* the defeating change is sorting by `(group, rel)` to make the grid render contiguously, which looks right and silently changes what "the third one" means. The test renders a booth whose groups interleave in `sorted(rel)` order and asserts the rendered item sequence is **byte-identical** with grouping on and off, and that `image_chain` is unchanged under every filter. **INV-3 — a rail that cannot navigate does not render, in EITHER direction of degeneracy.** The rail is absent unless grouping is informative: **two or more groups, and the middle group holding more than one item.** - **(a) one group for everything.** Live specimen `sc-iso-spread`: `DSC0001.jpg`–`DSC0006.jpg`, one group, six images. A rail with a single row cannot navigate. - **(b) one group per item.** Live specimens `pewpew-ui-brief` (23 groups for 34 items) and `dfa-concepts` (13 for 20). A rail with a row per tile is a second copy of the grid. *Falsifiable:* two defeating changes, each with its own test. `{% if rail.groups %}` in the template is true for a single group and true for N singletons — so the decision lives in Python, where it can be measured, and the template guard is the whole of it. Dropping the `>= 2` term reds `test_no_group_rail_when_there_is_only_one_group`; dropping the median term reds `test_no_group_rail_when_every_item_is_its_own_group`. Both mutations were RUN. ~~**SUPERSEDED — INV-3 as first written.**~~ *"one group renders NO rail …the test uses the real `sindra`-shaped fixture (thirty files, one prefix)."* Two things wrong with it, and the second is why this is kept: the `sindra` fixture does not exist (that booth yields 27 groups under the rule stated beside it, and 2 under the shipped one — `sc-iso-spread` is the real specimen), and it guarded only degeneracy (a) when (b) is the one the live set actually exhibits. A contract that had shipped as written would have put a 23-row rail on `pewpew-ui-brief`. **INV-4 — a filter is a link, not a script.** *Falsifiable:* the defeating change is binding filters to a click handler. The test fetches the filtered URL directly and asserts the server returned the filtered grid, with no JS executed. **INV-5 — the keyboard never fires on a booth with no grid.** *Falsifiable:* the defeating change is binding the handler unconditionally, so `f` on the standing link board flags nothing and swallows the keystroke. The test asserts the handler is not bound when `items` is empty. ## Out of scope - **Sections as a rail.** Measured worthless; `Item.section` is untouched. - **Compare mode.** Parked to v1.1 with its pairing rule. - **Virtualized loading.** Parked; measure first. - **A `.groups` override file.** See open questions. - **Anything on the verbatim path.** It has no grid.