feat(u7): the rail, the filters and the grid keyboard — the ratified three

ROADMAP's U7 row names four components. Three of them -- a sticky rail,
filters, and grid keyboard -- are already ratified there and are implemented
here. The fourth, replacing directory sections with filename-derived groups, is
a scope DEPARTURE the operator has not ruled on and is deliberately not built;
test_no_group_rail_is_shipped_yet fails the moment somebody builds it anyway,
so it cannot arrive by accident while he is away.

Filters are links carrying a query parameter, resolved server-side, so the
gallery keeps working with JavaScript off -- U3 already cost the verbatim path
its no-JS operation and said so, and the gallery is the surface the operator
actually reviews on. An unknown filter falls back to `all` rather than indexing
a dict by a value that arrives from an operator-editable URL.

`unanswered` means HAS AN OPEN PICK, the U4 hold predicate that already exists.
The other reading is a real and different question and stays open on the
contract rather than being guessed at.

Filtering is a VIEW and never reorders. The grid renders `sorted(rel)` with
non-matching items removed, so "the third one" means the same thing with a
filter on as with it off, and the zoom ring is untouched by any filter -- a
ring that changed with the grid would make `next` depend on how the operator
arrived, which is the misfiled-judgment failure invariant 6 exists for.

⚠ The first version of that invariant's test was VACUOUS and the mutation run
caught it: it compared each filtered view against the unfiltered RESPONSE, so a
reversing mutation reversed both sides and it stayed green under the exact
change it forbade. Rewritten against an independent truth -- U1 INV-3 says the
order IS sorted(rel) -- and re-verified RED. Written an hour after the entry
describing this exact failure class, which is worth recording.

611 -> 623 tests.
This commit is contained in:
vh
2026-09-22 14:45:57 -07:00
parent b50f41bb36
commit a306e2dc6d
5 changed files with 316 additions and 7 deletions
+50 -3
View File
@@ -859,7 +859,7 @@ def create_app(
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307) return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307)
@app.get("/b/{name}/", response_class=HTMLResponse) @app.get("/b/{name}/", response_class=HTMLResponse)
def booth_view(request: Request, name: str, download: int = 0): def booth_view(request: Request, name: str, download: int = 0, filter: str = "all"):
booth = resolve_booth(name) booth = resolve_booth(name)
# U4: viewing is activity. ABOVE both early returns — the zip download # U4: viewing is activity. ABOVE both early returns — the zip download
# and the verbatim-index.html branch are looks at this booth too, and a # and the verbatim-index.html branch are looks at this booth too, and a
@@ -902,6 +902,7 @@ def create_app(
held_marks, read_err = hold_read(booth) # ONE read; see list_booths held_marks, read_err = hold_read(booth) # ONE read; see list_booths
hold = hold_reason(held_marks, read_err) hold = hold_reason(held_marks, read_err)
marks = held_marks if read_err is None else marks_for(booth) marks = held_marks if read_err is None else marks_for(booth)
rail, shown = _rail(gallery, marks, filter)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"booth.html", "booth.html",
@@ -912,7 +913,16 @@ def create_app(
# The page could not previously tell keep from release, so it # The page could not previously tell keep from release, so it
# offered neither and you had to go back to the index. # offered neither and you had to go back to the index.
"kept": is_kept(booth), "kept": is_kept(booth),
"items": gallery, # THE GRID RENDERS `shown`; everything else reads `gallery`.
# Filtering is a VIEW: `shown` is `gallery` with non-matching
# items removed and NOTHING re-sorted, so "the third one" means
# the same thing with a filter on as with it off. Sorting by
# anything filter-derived would look right and silently misfile
# the operator's judgment — CLAUDE.md invariant 6.
"items": shown,
"all_items": gallery,
"rail": rail,
"filter": rail["active"],
# A booth carrying links.md is the standing link board: render # A booth carrying links.md is the standing link board: render
# its rows as real UI (link, provenance, pin, per-row + bulk # its rows as real UI (link, provenance, pin, per-row + bulk
# remove) instead of a markdown blob you can only edit by hand. # remove) instead of a markdown blob you can only edit by hand.
@@ -951,7 +961,9 @@ def create_app(
"marks": marks, "marks": marks,
"marks_open": len(open_marks(marks)), "marks_open": len(open_marks(marks)),
# Per-item marks, keyed by rel, so a tile reads its own judgment # Per-item marks, keyed by rel, so a tile reads its own judgment
# without every tile re-filtering the whole list. # without every tile re-filtering the whole list. Keyed off the
# FULL gallery, not the filtered one, so a tile hidden by the
# current filter still has its marks if the filter changes.
"item_marks": { "item_marks": {
it["name"]: marks_for_target(marks, it["name"]) for it in gallery it["name"]: marks_for_target(marks, it["name"]) for it in gallery
}, },
@@ -969,6 +981,41 @@ def create_app(
}, },
) )
FILTERS = ("all", "flagged", "annotated", "unanswered")
def _rail(gallery: list[dict], marks, requested: str) -> tuple[dict, list[dict]]:
"""Per-filter counts, and the items the grid should render.
`requested` ARRIVES FROM A URL, which is operator-editable and
link-shared, so an unknown value falls back to `all` rather than
indexing a dict by it. A filter nobody can mistype into a 500.
`unanswered` means HAS AN OPEN PICK — the U4 hold predicate, which
already exists and already has a home. The other reading ("has no mark
at all") is a genuinely different question and is an open question on
the U7 contract, not something to guess at here.
"""
active = requested if requested in FILTERS else "all"
open_ids = {m.id for m in open_marks(marks)}
buckets: dict[str, list[dict]] = {f: [] for f in FILTERS}
for it in gallery:
mine = marks_for_target(marks, it["name"])
buckets["all"].append(it)
if any(m.shape == "flag" and m.flagged for m in mine):
buckets["flagged"].append(it)
if any(m.shape == "note" for m in mine):
buckets["annotated"].append(it)
if any(m.id in open_ids for m in mine):
buckets["unanswered"].append(it)
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),
}
return rail, buckets[active]
def _board_rows(booth: Path) -> list[dict]: def _board_rows(booth: Path) -> list[dict]:
"""The link board's rows, or [] for a board that cannot be read. """The link board's rows, or [] for a board that cannot be read.
+10
View File
@@ -520,6 +520,16 @@
/* A board row whose booth has been swept. Marked, never auto-removed. */ /* A board row whose booth has been swept. Marked, never auto-removed. */
.board-row.board-dead{opacity:.45} .board-row.board-dead{opacity:.45}
.board-dead-tag{font-size:.9em;color:#f2b8b5;opacity:.9} .board-dead-tag{font-size:.9em;color:#f2b8b5;opacity:.9}
/* U7 — the rail, and the grid cursor. */
.rail{position:sticky;top:0;z-index:5;display:flex;gap:.5rem;align-items:baseline;
padding:.4rem .6rem;margin:.6rem 0;background:var(--bg,#111);
border-bottom:1px solid var(--line,#2a2a2a);flex-wrap:wrap}
.rail-total{font-weight:600}
.rail-f{font-size:.85em;padding:.1rem .45rem;border-radius:3px;text-decoration:none;
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)}
figure.item.is-cursor{outline:2px solid #7aa2f7;outline-offset:2px}
</style> </style>
</head> </head>
<body> <body>
+73 -1
View File
@@ -243,7 +243,29 @@
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its {# `elif items` and not a bare `else`: a board booth has NO gallery items (its
links.md is rendered as the board above and filtered out), so a plain else links.md is rendered as the board above and filtered out), so a plain else
would emit an empty <div class="gallery"> under the board. #} would emit an empty <div class="gallery"> under the board. #}
<div class="gallery"> {# THE RAIL. Totals and per-filter counts, as LINKS with a query parameter —
resolved server-side, so the whole thing works with JavaScript off. The
gallery is the surface the operator actually reviews on and U3 already
cost the verbatim path its no-JS operation; this one does not repeat that.
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. #}
<div class="rail">
<span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span>
{% for f in rail.counts %}
<a class="rail-f{% if f.key == filter %} on{% endif %}"
data-filter="{{ f.key }}"
href="/b/{{ name_url }}/{% if f.key != 'all' %}?filter={{ f.key }}{% endif %}"
{% if f.key == filter %}aria-current="true"{% endif %}>{{ f.key }} <b>{{ f.n }}</b></a>
{% endfor %}
</div>
<div class="gallery" id="grid" tabindex="-1">
{% for it in items %} {% for it in items %}
{% if it.doc and it.rendered is not none %} {% if it.doc and it.rendered is not none %}
{# Docs render INLINE, collapsible, and closable — not a link to a {# Docs render INLINE, collapsible, and closable — not a link to a
@@ -329,6 +351,56 @@
</div> </div>
{% endif %} {% endif %}
{% if items %}
<script id="gridkeys">
/* GRID KEYBOARD — U7. Additive by construction: every action it reaches is a
control that already exists on the tile and already works with a mouse, so
the page is complete without this file. It is bound ONLY when there is a
grid ({% raw %}{% if items %}{% endraw %} above): binding it on the standing
link board would swallow `f` and flag nothing.
Focus moves in RENDER ORDER, which is the item order filtered by the current
filter and never re-sorted — so `→` walks the grid in the same sequence the
operator reads it, and the same sequence the zoom ring uses. */
(function () {
var grid = document.getElementById('grid');
if (!grid) return;
var tiles = function () { return [].slice.call(grid.querySelectorAll('figure.item')); };
var at = -1;
function focus(i) {
var t = tiles();
if (!t.length) return;
at = Math.max(0, Math.min(i, t.length - 1));
t.forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
t[at].scrollIntoView({ block: 'nearest' });
}
function current() { var t = tiles(); return at >= 0 && at < t.length ? t[at] : null; }
function click(sel) {
var el = current(); if (!el) return;
var b = el.querySelector(sel); if (b) b.click();
}
document.addEventListener('keydown', function (e) {
/* Never steal a key the operator is typing into a note or a URL bar. */
var tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case 'ArrowRight': focus(at + 1); e.preventDefault(); break;
case 'ArrowLeft': focus(at <= 0 ? 0 : at - 1); e.preventDefault(); break;
case 'f': click('.flagbtn, [name="target"]'); e.preventDefault(); break;
case 'n': var el = current();
if (el) { var f = el.querySelector('input[type=text], textarea');
if (f) { f.focus(); e.preventDefault(); } }
break;
case 'Enter': click('a[href^="view"]'); break;
case 'Escape':
tiles().forEach(function (x) { x.classList.remove('is-cursor'); });
at = -1; break;
}
});
})();
</script>
{% endif %}
<script> <script>
/* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain /* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain
HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context
+13 -3
View File
@@ -1,6 +1,6 @@
--- ---
contract_version: "0.1-PROPOSED" 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." status: "PARTIALLY LANDED. The three components ROADMAP already ratifies — rail, filters, grid keyboard — are implemented and deployed (tests/test_navigation.py, 12 tests). The FOURTH, replacing directory sections with filename-derived groups, is NOT built and is the one scope-direction call below. 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)" 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." 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: depends_on:
@@ -36,8 +36,18 @@ open_questions:
# U7 — navigation at the size the booths actually are # U7 — navigation at the size the booths actually are
**⚠ PROPOSED. The scope departs from ROADMAP on measured grounds; the operator **⚠ PARTIALLY LANDED, DELIBERATELY.**
has not ruled. Nothing here is implemented.**
| 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** | **NOT BUILT.** The one scope-direction call here, and it is the operator's. `test_no_group_rail_is_shipped_yet` fails the moment somebody builds it anyway, so the departure cannot arrive by accident. |
`unanswered` was taken to mean **has an open pick** — the U4 hold predicate,
which already exists and already has a home. The other reading ("has no mark at
all") is a real and different question and stays an open question below.
## The defect, re-measured rather than inherited ## The defect, re-measured rather than inherited
+170
View File
@@ -0,0 +1,170 @@
"""U7 slice 1 — the rail, the filters, the grid keyboard.
⚠ SCOPE. This covers ONLY the three components ROADMAP's U7 row already
ratifies: a sticky rail, filters, and grid keyboard. The fourth — replacing
directory sections with filename-derived groups — is a scope DEPARTURE proposed
in `docs/contracts/u7_navigation.contract.md` and is deliberately NOT built
here, because it is the operator's call and he has not made it. The rail
therefore carries totals and filter counts and no jump-to-group anchors yet.
`unanswered` is taken to mean HAS AN OPEN PICK — the U4 hold predicate, which
already exists and already has a home. The alternative reading ("has no mark at
all") is a real and different question and is the contract's open question.
"""
from __future__ import annotations
import json
import pathlib
import sys
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import create_app # noqa: E402
from booth.marks import declare_pick, set_flag, write_note # noqa: E402
PNG = b"\x89PNG\r\n\x1a\n"
@pytest.fixture
def gallery(tmp_path):
"""A booth with one of each: flagged, annotated, open pick, and plain."""
b = tmp_path / "g"
b.mkdir()
for n in ("a.png", "b.png", "c.png", "d.png"):
(b / n).write_bytes(PNG)
set_flag(b, "a.png", True)
write_note(b, "b.png", "a remark")
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}, target="c.png")
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
return TestClient(app), b
def _tiles(body: str) -> list[str]:
"""The rels the grid actually rendered, in render order."""
import re
# `data-item` already exists on every tile (both the doc and media
# variants). Reusing it rather than adding a parallel `data-rel` is the
# same one-fact-one-place discipline INV-1 states for item facts.
return re.findall(r'data-item="([^"]+)"', body)
def test_the_rail_counts_every_filter(gallery):
c, _ = gallery
body = c.get("/b/g/").text
assert 'class="rail"' in body
for token in ("all", "flagged", "annotated", "unanswered"):
assert f'data-filter="{token}"' in body, token
@pytest.mark.parametrize("flt,expected", [
("all", ["a.png", "b.png", "c.png", "d.png"]),
("flagged", ["a.png"]),
("annotated", ["b.png"]),
("unanswered", ["c.png"]),
])
def test_a_filter_narrows_the_grid_server_side(gallery, flt, expected):
"""INV-4: a filter is a LINK, not a script. Fetched directly, with no JS
executed, the server must return the narrowed grid.
Defeating change: binding filters to a click handler and returning the full
grid for every URL — under which this test gets four tiles every time."""
c, _ = gallery
assert _tiles(c.get(f"/b/g/?filter={flt}").text) == expected
def test_filtering_never_reorders(gallery):
"""INV-2, the load-bearing one. Grouping and filtering are VIEWS.
The defeating change is sorting the grid by anything derived from the
filter — which looks right and silently changes what "the third one" means,
the misfiled-judgment failure CLAUDE.md invariant 6 exists to prevent.
Asserted as a SUBSEQUENCE rather than a set: order is the property, so a
filter that returned the right tiles in the wrong sequence must go red."""
c, _ = gallery
# ⚠ THE BASELINE IS COMPUTED INDEPENDENTLY, and that is the whole test.
# The first version of this compared each filtered view against the
# UNFILTERED RESPONSE — and a mutation that reversed the order reversed
# both sides, so it stayed green under the exact change it forbade. Caught
# by running the mutation rather than trusting the assertion, which is the
# discipline in persistent-memory.d/2026-09-22-vacuous-falsifiers.md and
# which this test failed first time out.
#
# The independent truth is U1 INV-3: the item order IS `sorted(rel)`. So
# each filtered view must be sorted, full stop, with no reference to any
# other response.
for flt in ("all", "flagged", "annotated", "unanswered"):
got = _tiles(c.get(f"/b/g/?filter={flt}").text)
assert got == sorted(got), f"{flt} rendered out of sorted(rel) order: {got}"
# and every filtered view is a SUBSEQUENCE of the true order, not a reshuffle
every = sorted(["a.png", "b.png", "c.png", "d.png"])
for flt in ("all", "flagged", "annotated", "unanswered"):
got = _tiles(c.get(f"/b/g/?filter={flt}").text)
assert got == [r for r in every if r in got], flt
def test_an_unknown_filter_falls_back_to_all_and_does_not_500(gallery):
"""A filter arrives from a URL, which is operator-editable and link-shared.
Defeating change: indexing a dict by the raw parameter."""
c, _ = gallery
for junk in ("nonsense", "", "../../etc", "flagged;drop"):
r = c.get(f"/b/g/?filter={junk}")
assert r.status_code == 200, junk
assert len(_tiles(r.text)) == 4, junk
def test_the_zoom_ring_is_identical_under_every_filter(gallery):
"""The ring is the item order filtered to images and must not notice the
grid's filter — otherwise `next` means something different depending on how
the operator arrived, and a flag lands on the wrong artifact.
Defeating change: building the ring from the filtered list."""
c, _ = gallery
rings = set()
for flt in ("all", "flagged", "annotated", "unanswered"):
c.get(f"/b/g/?filter={flt}")
body = c.get("/b/g/b.png?view=1").text
import re
rings.add(tuple(re.findall(r'href="([^"]*\.png[^"]*)"', body)))
assert len(rings) == 1, f"the ring changed with the filter: {rings}"
def test_the_rail_is_absent_on_a_booth_with_no_grid(tmp_path):
"""INV-5's sibling: a rail over nothing is chrome. The standing link board
has no items, so it must not render one."""
b = tmp_path / "links"
b.mkdir()
(b / "links.md").write_text("- [r](https://x.test/) <sub>· a · 2026-09-01 00:00</sub>\n")
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
assert 'class="rail"' not in c.get("/b/links/").text
def test_the_keyboard_is_not_bound_when_there_is_no_grid(tmp_path):
"""INV-5. Defeating change: binding the handler unconditionally, so `f` on
the standing link board swallows the keystroke and flags nothing."""
b = tmp_path / "links"
b.mkdir()
(b / "links.md").write_text("- [r](https://x.test/) <sub>· a · 2026-09-01 00:00</sub>\n")
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
assert "gridkeys" not in c.get("/b/links/").text
def test_the_keyboard_is_bound_when_there_is_one(gallery):
c, _ = gallery
assert "gridkeys" in c.get("/b/g/").text
def test_no_group_rail_is_shipped_yet(gallery):
"""⚠ SCOPE GUARD, and it is deliberate. Replacing directory sections with
filename-derived groups is a scope DEPARTURE from ROADMAP's U7 row that the
operator has not ruled on. This test fails the moment somebody builds it
anyway, so the departure cannot arrive by accident while he is away."""
c, _ = gallery
body = c.get("/b/g/").text
assert "data-group" not in body
assert 'class="rail-groups"' not in body
assert 'class="rail"' in body, "the approved rail must still be here"