diff --git a/ROADMAP.md b/ROADMAP.md index 660edd2..d45addd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,9 @@ # The Booth — roadmap Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md). -Current version: `0.6.1` (U1 through U6 landed; extracted from eshpfi 2026-09-21). +Current version: `0.6.1` (**U1 through U7 landed — every v1 capability is in**; +extracted from eshpfi 2026-09-21). **The 1.0 cut is now a decision, not a +dependency**, and it is the operator's: a major bump needs his approval. ## v1 target @@ -16,26 +18,29 @@ defect — not a wish. The measurements are in the IA doc. | 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 | | 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 | | 6 | ~~**Benches**~~ — **landed `1c3ce5d`, released `v0.6.0`** | 69% link-board rot (re-measured: 178 booth rows + 8 bench re-posts) | U6 | -| 7 | **Navigation** — ~~sections~~ **filename groups**, rail, filters, grid keyboard | one flat wall; 0 of 11 galleries have subfolders, so grouping comes from the filename | U7 | +| 7 | ~~**Navigation**~~ — ~~sections~~ **filename groups**, rail, filters, grid keyboard — **landed, unreleased** | one flat wall; 0 of 11 galleries have subfolders, so grouping comes from the filename | U7 | Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent** of all of them (different storage, different surface) and therefore the safest thing to land first or in parallel. -**U1 through U6 are landed.** **U7 is the last unit before the 1.0 cut** — its -only dependency was `{U3, U4, U5}` and that closed with U3. +**ALL SEVEN UNITS ARE LANDED.** U7 closed last; its only dependency was +`{U3, U4, U5}` and that closed with U3. -⚠ **Before starting U7, read -`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`, and re-count -the booths first.** Half its premise is already known to be wrong — every booth -that actually needs navigation is FLAT — and the booth set churned again on -2026-09-22: the four large booths U7 was sized against (`pancake-v3-full` and -`pancake-v4-full` at 270 items, `sindra20-engines`, `sindra-finalists`) have all -been swept. The largest live booth is now `miranda-is` at 92 items, flat. Two of -23 booths have subfolders (`pewpew-ui-brief`, `dfa-concepts`) and **both are -reports** — the job where grid navigation matters least. Sections, one of U7's -four named components, buys close to nothing. The rail, the filters and the grid -keyboard are the unit. +⚠ **What U7 actually shipped is not what this row first described, and the +difference is measured.** Sections were dropped for filename-prefix groups +(operator-ratified 2026-09-22) because zero of eleven gallery booths have a +subdirectory. Then the *grouping rule itself* changed at implementation: the +contract's `strip a trailing digit run` yields 24 groups for `sindra-bakeoff`'s +40 images and 27 for `sindra`'s 30 — a rail with a row per tile — because it +keys on the end of the stem, where the instance number lives. The shipped rule +keys on the **first separator-delimited segment**, where the family lives, and +gives 4 and 2. The full re-measurement across all 17 live booths is in +`docs/contracts/u7_navigation.contract.md`. + +**The v1 target is met.** What remains is a release decision the operator owns: +cut `1.0`, or take a `0.7.0` staging release first. Nothing in the code is +waiting on it. **U5's adoption is a measured prediction, not a finished result**, and it is TWO predictions rather than one. The operator declined a fleetwide announcement @@ -85,6 +90,8 @@ Where it already binds, and what the rule is in each case: | legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them | | link board rows | pinned first, then newest-first | | a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) | +| **groups among themselves** | **the position of each group's first member in the rendered sequence** — `sorted(rel)` narrowed by the filter, never re-sorted. Walking the rendered list once into an insertion-ordered dict IS the rule, so there is no second sort to drift from it (U7) | +| **items within a group** | not a separate order — a group is a label on a tile, not a container. The grid stays `sorted(rel)` and groups interleave in it freely (U7) | | the bench registry | `(state rank, name casefolded, id)` — live before promoted before retired, then alphabetical, with the id as a TOTAL tie-break so two benches sharing a name cannot swap (U6) | | the link board's dead marker | not an order — a per-row stamp read from the existing `order_for_display` sequence, so marking cannot move a row (U6) | | embed anchors in a verbatim report | **document order** — what `querySelectorAll` yields, so the author's markup decides (U3) | @@ -101,18 +108,18 @@ surfaces (index card, booth header, marks page) render through ONE macro precisely so they cannot disagree, which is the same property stated for ordering: one rule, one place, every surface reading it. -**U7's group ordering is SETTLED** (operator, 2026-09-22): groups order by the -position of their first member in `sorted(rel)`, so the rail reads in the same -direction as the grid. Subfolder sections are dropped in favour of -filename-prefix groups on measured grounds — zero of eleven gallery booths have -a subdirectory. Compare pairing rides with compare mode into v1.1. +**U7's group ordering is SETTLED and SHIPPED** (operator, 2026-09-22): groups +order by the position of their first member in the rendered sequence, so the +rail reads in the same direction as the grid. Subfolder sections were dropped +in favour of filename-prefix groups on measured grounds — zero of eleven +gallery booths have a subdirectory. -Formerly undecided, retained for the record: **U7's -section ordering and its compare pairing** (sections need a stated order among -themselves, not just within; pairing by filename needs a rule for what happens -to an unpaired file). **U6's bench listing is settled** — the row above. -Compare pairing is parked to v1.1 with compare mode itself, so U7 carries one -undecided rule, not two. +**Nothing in this table is undecided any more.** The two U7 rules that were +(section ordering among themselves, compare pairing) resolved differently: +section ordering is MOOT, because U7 renders no section rail — `Item.section` +still exists and is still derived, it simply has no ordered surface. Compare +pairing rode into v1.1 with compare mode itself. **U6's bench listing is +settled** — the row above. The test for any new ordered surface: *can you write the rule down in one line?* If not, it does not have one yet. diff --git a/booth/app.py b/booth/app.py index 60340a2..c1e434c 100644 --- a/booth/app.py +++ b/booth/app.py @@ -520,6 +520,8 @@ def build_gallery(child: Path) -> list[dict]: "doc": it.doc, "url": it.url, "section": it.section, + # U7. Derived in the resolver (INV-1); this only carries it. + "group": it.group, "caption": it.caption, "rendered": rendered, "rendered_html": rendered_html, @@ -1007,14 +1009,57 @@ def create_app( buckets["annotated"].append(it) if any(m.id in open_ids for m in mine): buckets["unanswered"].append(it) + shown = buckets[active] rail = { "active": active, # ORDER: the declaration order of FILTERS. Stated because a rail is # an ordered collection and invariant 6 binds to it like any other. "counts": [{"key": f, "n": len(buckets[f])} for f in FILTERS], "total": len(gallery), + "groups": _groups(shown), } - return rail, buckets[active] + return rail, shown + + def _groups(shown: list[dict]) -> list[dict]: + """The jump-to-group rows, or [] when grouping would not help. + + DERIVED FROM `shown`, NOT FROM THE FULL GALLERY, so every anchor lands + on a tile the page actually rendered. A row pointing at an item the + current filter has hidden scrolls nowhere, which is the same defect as + a wrong id arriving by a different route. + + ORDER: 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 direction the grid does, and adding + a file reshuffles nothing unless it lands first in its group. Settled by + the operator 2026-09-22; ROADMAP carries the row. `dict` preserves + insertion order, so walking `shown` once IS the rule. + + ⚠ THE RAIL IS ABSENT UNLESS GROUPING IS INFORMATIVE: two or more + groups, and the middle group holding more than one item. TWO + degeneracies, not one. The contract named only the first -- + `sindra` and `sc-iso-spread` put every file in ONE group, and a rail + with a single row cannot navigate. The second is the one the live set + actually exhibits: `pewpew-ui-brief` yields 23 groups for 34 items and + `dfa-concepts` 13 for 20, a rail that is a second copy of the grid. + 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. + """ + by_group: dict[str, list[dict]] = {} + for it in shown: + if it["group"] is not None: + by_group.setdefault(it["group"], []).append(it) + sizes = sorted(len(v) for v in by_group.values()) + if len(sizes) < 2 or sizes[len(sizes) // 2] <= 1: + return [] + return [ + # The anchor is the FIRST member's existing tile id. The template + # already stamps `id="item-"` on every figure; minting a + # parallel `#group-` would be a second identity for one tile. + {"key": k, "n": len(v), "anchor": f"item-{v[0]['name']}"} + for k, v in by_group.items() + ] def _board_rows(booth: Path) -> list[dict]: """The link board's rows, or [] for a board that cannot be read. diff --git a/booth/items.py b/booth/items.py index 6c68509..77b428a 100644 --- a/booth/items.py +++ b/booth/items.py @@ -15,6 +15,7 @@ See docs/contracts/u1_item_record.contract.md. from __future__ import annotations +import re from dataclasses import dataclass from pathlib import Path from typing import Sequence @@ -89,6 +90,7 @@ class Item: url: str kind: str section: str | None + group: str | None caption: str | None blurred: bool doc: str | None @@ -115,6 +117,43 @@ def _section_of(rel: str) -> str | None: return None if str(parent) == "." else parent.as_posix() +# One separator run between name segments. A filename is the only grouping +# signal the live booths actually carry: 0 of 11 galleries have a subdirectory. +_SEG = re.compile(r"[-_. ]+") + + +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, so the digits are the separator); + `v30-seed8302.png` -> `v30` (separator present, so `v30` survives and does + not merge with `v35`, which is the axis that booth is about). + + None for a stem with nothing before the digits -- `01.png` has no prefix to + group on, and inventing one would file every numbered render under the + empty string. + + ⚠ THIS IS NOT THE RULE THE CONTRACT FIRST NAMED. `strip ONE trailing run of + digits` was measured against the live set on 2026-09-22 and yields 24 groups + for sindra-bakeoff's 40 images and 27 for sindra's 30 -- a rail with one row + per tile. The contract's own table claimed 5 and 1 for those two booths; + neither reproduces under the rule it states beside them. The rewritten table + carries the re-measurement. + + Derived HERE and nowhere else (INV-1). A route body that re-derived it would + be the caption bug in a new field. + """ + stem = Path(rel).stem # basename without its last suffix; `a.tar.gz` -> `a.tar` + segs = _SEG.split(stem) + if len(segs) == 1: + return re.sub(r"\d+$", "", stem) or None + return segs[0] or None + + def _resolve_captions(by_rel: dict[str, Path]) -> tuple[dict[str, str], set[str]]: """(caption-by-rel, rels consumed as sidecars). @@ -203,6 +242,7 @@ def booth_items(booth: Path) -> list[Item]: url=quote(rel, safe="/"), kind=classify(p.name), section=_section_of(rel), + group=_group_of(rel), caption=caption.get(rel), blurred=rel in blurred, doc=doc_kind(p.name), diff --git a/booth/templates/base.html b/booth/templates/base.html index 823d403..710e4f4 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -529,6 +529,15 @@ opacity:.65;border:1px solid transparent} .rail-f:hover{opacity:1} .rail-f.on{opacity:1;border-color:var(--line,#2a2a2a);background:rgba(255,255,255,.06)} + /* The group row. Wraps rather than scrolls: 16 groups is the live maximum + and a horizontal scroller hides half of them behind a gesture. */ + .rail-groups{display:flex;gap:.35rem;flex-wrap:wrap;align-items:baseline; + padding-left:.5rem;margin-left:.25rem;border-left:1px solid var(--line,#2a2a2a)} + .rail-g{font-size:.8em;padding:.1rem .4rem;border-radius:3px;text-decoration:none; + opacity:.6;border:1px solid transparent} + .rail-g:hover{opacity:1;border-color:var(--line,#2a2a2a)} + /* The jumped-to tile, so a fragment jump says where it landed. */ + figure.item:target{outline:2px dashed #7aa2f7;outline-offset:3px} figure.item.is-cursor{outline:2px solid #7aa2f7;outline-offset:2px} diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 78656d6..2ea19df 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -251,11 +251,11 @@ ORDER: the declaration order of FILTERS in app.py. A rail is an ordered collection and invariant 6 binds to it like any other. - ⚠ NO JUMP-TO-GROUP ANCHORS YET. Replacing directory sections with - filename-derived groups is a scope departure from ROADMAP's U7 row that - the operator has not ruled on; see docs/contracts/u7_navigation.contract.md - and tests/test_navigation.py::test_no_group_rail_is_shipped_yet, which - fails the moment somebody builds it anyway. #} + THE GROUP ROW is `rail.groups`, which is EMPTY unless grouping is + informative — see `_groups` in app.py. `{% raw %}{% if rail.groups %}{% endraw %}` + is therefore the whole guard; the two degenerate cases (one group for + everything, one group per item) are decided in Python, where they can be + measured, rather than by a count in a template. #}
{{ rail.total }} item{{ '' if rail.total == 1 else 's' }} {% for f in rail.counts %} @@ -264,6 +264,17 @@ href="/b/{{ name_url }}/{% if f.key != 'all' %}?filter={{ f.key }}{% endif %}" {% if f.key == filter %}aria-current="true"{% endif %}>{{ f.key }} {{ f.n }} {% endfor %} + {% if rail.groups %} + + {% endif %}