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)
@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)
# 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
@@ -902,6 +902,7 @@ def create_app(
held_marks, read_err = hold_read(booth) # ONE read; see list_booths
hold = hold_reason(held_marks, read_err)
marks = held_marks if read_err is None else marks_for(booth)
rail, shown = _rail(gallery, marks, filter)
return templates.TemplateResponse(
request,
"booth.html",
@@ -912,7 +913,16 @@ def create_app(
# The page could not previously tell keep from release, so it
# offered neither and you had to go back to the index.
"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
# its rows as real UI (link, provenance, pin, per-row + bulk
# remove) instead of a markdown blob you can only edit by hand.
@@ -951,7 +961,9 @@ def create_app(
"marks": marks,
"marks_open": len(open_marks(marks)),
# 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": {
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]:
"""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. */
.board-row.board-dead{opacity:.45}
.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>
</head>
<body>
+73 -1
View File
@@ -243,7 +243,29 @@
{# `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
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 %}
{% if it.doc and it.rendered is not none %}
{# Docs render INLINE, collapsible, and closable — not a link to a
@@ -329,6 +351,56 @@
</div>
{% 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>
/* 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