Not approved and not implemented. Frontmatter status says so, the body says so twice, and the one scope-direction call in it is named as the operator's. ROADMAP's U7 row is sections, rail, filters, grid keyboard. The measurement recorded in persistent-memory.d/2026-09-22-u7-remeasured-before-scoping.md kills the first component -- zero of eleven gallery booths have a subdirectory, and the only two booths that do are reports -- and supplies a replacement: stripping a trailing digit-run from the filename stem yields 5 to 16 sensible groups on four of the five large galleries. The degenerate fifth is carried as a first-class case rather than an edge: one group must render NO rail, because a navigation affordance that cannot navigate is worse than none. Closes ROADMAP's outstanding U7 ordering question: groups order by the position of their first member in sorted(rel), so the rail reads in the same direction as the grid. Grouping and filtering are views and never reorder -- INV-2 exists because sorting by (group, rel) looks right and silently changes what 'the third one' means, which is the misfiled-judgment failure invariant 6 was written for. Blast radius checked before writing: Item gains one field beside the existing section, build_gallery carries it, and image_chain is explicitly unchanged.
151 lines
11 KiB
Markdown
151 lines
11 KiB
Markdown
---
|
|
contract_version: "0.1-PROPOSED"
|
|
status: "PROPOSED — NOT APPROVED. The scope below departs from ROADMAP's U7 row on measured grounds and the operator has not ruled on it. Do not implement, and do not treat this as settled, until he has."
|
|
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 DEPARTS FROM ROADMAP AND THE OPERATOR HAS NOT APPROVED IT. ROADMAP's U7 row says `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. THIS IS THE ONE THING IN THIS DOCUMENT THAT IS A SCOPE-DIRECTION CALL RATHER THAN AN IMPLEMENTATION ONE, and it is his."
|
|
- "THE GROUP HEURISTIC DEGENERATES, AND THAT IS A FIRST-CLASS CASE RATHER THAN AN EDGE. Measured: `sindra` yields ONE group for thirty images because every filename starts `00-`. A single group must render as NO rail rather than as a rail with one entry -- a navigation affordance that cannot navigate is worse than none, because it occupies the space where the real one would be."
|
|
- "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."
|
|
- "WHETHER `unanswered` AS A FILTER MEANS `has an open pick` OR `has no mark at all`. Both are useful and they are different questions; the first is the U4 hold predicate and already exists, the second is `show me what I have not looked at`. The rail names counts for both cheaply; the FILTER needs one of them chosen. Operator call, and it is small."
|
|
---
|
|
|
|
# U7 — navigation at the size the booths actually are
|
|
|
|
**⚠ PROPOSED. The scope departs from ROADMAP on measured grounds; the operator
|
|
has not ruled. Nothing here is implemented.**
|
|
|
|
## 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.
|
|
|
|
Strip a trailing digit-run from each stem and group on what remains:
|
|
|
|
| booth | images | groups | |
|
|
|---|---|---|---|
|
|
| `sindra-corpus-v1` | 66 | **16** | `ac01.png` |
|
|
| `sindra-nude-pool` | 42 | **12** | `flag-rear.png` |
|
|
| `sindra-sfw-pool` | 59 | **10** | `a01.png` |
|
|
| `sindra-bakeoff` | 40 | **5** | `00-sheet-c1-market-noon.png` |
|
|
| `sindra` | 30 | **1** | `00-b1-sheet-1.png` — **degenerates** |
|
|
|
|
Four of five. The competing heuristic — split on the second hyphen — yields 59
|
|
"groups" from 59 files and is useless everywhere. The degenerate fifth is the
|
|
case the design has to carry, not the case that invalidates it.
|
|
|
|
## 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: take the stem of the basename, strip ONE trailing run of digits
|
|
and any single separator before it, and return what remains. `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`.
|
|
|
|
Returns None for a stem that is ENTIRELY a digit run — `01.png` has no
|
|
prefix to group on, and inventing one would put every numbered file in a
|
|
group named after the empty string.
|
|
|
|
Derived HERE and nowhere else (INV-1). A route body that re-derived it
|
|
would be the caption bug in a new field.
|
|
"""
|
|
```
|
|
|
|
## 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 `sorted(rel)`** — 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 |
|
|
| 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 — one group renders NO rail.** *Falsifiable:* the defeating change is
|
|
`{% if groups %}`, which is true for a single group. The test uses the real
|
|
`sindra`-shaped fixture (thirty files, one prefix) and asserts the rail element
|
|
is absent — not merely that it lists one entry.
|
|
|
|
**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.
|