fix(r2): the heid code-review panel (round "Wren", 4/4) — triaged and folded

Code fixes:
- The narrow-screen fold was specified and never built (4/4). The tray and
  notes are now closed <details> in the aside; above 1000px CSS alone
  (::details-content) shows them and hides the summary. There is no
  script. Browser-tested at 390 and 1400, JS on and off.
- The lightbox gated on parsed board rows, not page identity (3/4). It now
  uses is_board, the lesson the bench panel already carried.
- wants_json returned True at the first good entry, so a malformed later
  entry was never read (3/4). It now parses every entry first; any error
  is False.
- One flag predicate, flagged_targets. It serves the Desk count, the tray,
  the filmstrip, the tape and the review button. An unreadable flag entry
  counts nowhere.
- The header's open count and lifetime line, and the no-set marks panel,
  are now regions (they were stale after an in-place answer).
- Inline group headers render only when every group is one contiguous run.
  Interleaved directories no longer reprint or misfile headers.
- A booth held unreadable has no open_since, even with a readable pick
  beside the damage.
- The swap marks an absent region is-stale instead of leaving it looking
  current. It carries disclosure state (except the sent form's). The
  failure message is readable for 0.9 s before the reload.

Contract amended where the code was right and the text was not: the
wants_json and record_seen signatures, landed_at's three refinements, the
group position being ring-based, the end of the set offering every other
open pick, the Space-key player exception, and the fold mechanism.

New tests cover the parse order; a board with media; the header region; the
no-set panel; interleaved groups; mixed damage; the flag predicate; the
review recording .viewed; the fold at two widths with JS on and off; the
status message before the reload; a lost response after a landed write
(exactly one note); a stale absent region; stage node identity across a
swap; and F with a radio focused. The lost-response and stale tests turn
red under their mutations. 724 passed.
This commit is contained in:
vh
2026-09-23 09:41:18 -07:00
parent 881c7f5df3
commit fa5d46443d
7 changed files with 404 additions and 41 deletions
+48 -12
View File
@@ -419,21 +419,48 @@ def wants_json(accept: str | None) -> bool:
"""
if not accept:
return False
# EVERY entry is parsed before anything is decided: returning True at the
# first good JSON entry meant a malformed one after it was never read, so
# `application/json, application/json;q=broken` was a 204 and the same pair
# reversed a 303 (heid code-review, 3/4). One unparseable entry anywhere
# makes the whole header False.
wanted = False
try:
for entry in accept.split(","):
mtype, *params = entry.split(";")
if mtype.strip().lower() != "application/json":
continue
q = 1.0
for param in params:
key, _, value = param.partition("=")
if key.strip().lower() == "q":
q = float(value.strip())
if q > 0:
return True
if mtype.strip().lower() == "application/json" and q > 0:
wanted = True
except ValueError:
return False
return False
return wanted
def flagged_targets(marks: Sequence[Mark]) -> set[str]:
"""THE flag predicate (R2): the items carrying a READABLE flag mark. The
Desk count, the tray, the filmstrip, the tape and the review button all
read this, so they cannot disagree about one item. An unreadable flag
entry is judgment nobody can see, and counts nowhere."""
return {m.target for m in marks
if m.shape == "flag" and m.error is None and m.target}
def _contiguous(keys: Sequence[str | None]) -> bool:
"""True when each non-None key occupies ONE unbroken run of the sequence."""
seen: set[str] = set()
prev = object()
for k in keys:
if k != prev:
if k is not None and k in seen:
return False
if k is not None:
seen.add(k)
prev = k
return True
# The Desk shows this many bookmarks and links to the board for the rest.
@@ -615,8 +642,12 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
"mtime": mtime,
# ---- R2 C4, the Desk. All from the pass above; no second read.
"open_since": min(stamps) if stamps else None,
"flags": sum(1 for m in marks if m.shape == "flag"),
# A booth held for UNREADABLE marks has no `open_since`, even if
# a readable pick sits beside the damage: it sorts after every
# dated question, because the damage is what needs fixing.
"open_since": (min(stamps) if stamps and hold != HOLD_UNREADABLE
else None),
"flags": len(flagged_targets(marks)),
# Two clocks, named apart (INV-5): `mtime` is activity,
# `landed_at` is content. "New since you looked" reads only the
# second, so a flag or a view never makes a booth look new.
@@ -1142,8 +1173,14 @@ def create_app(
# R2 C5: the flag tray — the flagged items in SET order, i.e.
# by ordinal, over the full gallery (a filter hides tiles, not
# judgments). Order is total with no tie-break: rels are unique.
"tray": [it for it in gallery if it["name"] in {
m.target for m in marks if m.shape == "flag" and m.error is None}],
# Inline group headers only when every group is ONE contiguous
# run in the rendered order. Groups come from basenames and the
# order from full paths, so they can interleave (d1/aa, d1/bb,
# d2/aa); a header then either repeats or files an item under
# the wrong group. The rail's jump links do not depend on this.
"inline_groups": bool(rail["groups"]) and _contiguous(
[it["group"] for it in shown]),
"tray": [it for it in gallery if it["name"] in flagged_targets(marks)],
"ord_width": len(str(len(gallery))),
"uploaded": (booth / UPLOAD_MARKER).exists(),
# The same provenance line the index card carries. Deliberate:
@@ -1703,7 +1740,7 @@ def create_app(
# notes and the flag state travel to full size, which is the size at
# which the judgment is actually being made.
"marks": item_marks,
"flagged": any(m.shape == "flag" for m in item_marks),
"flagged": f in flagged_targets(marks),
}
if item is not None and item.kind in REVIEW_KINDS:
@@ -1718,8 +1755,7 @@ def create_app(
if len(ring) > 1:
prev_url = quote(ring[(pos - 1) % len(ring)], safe="/")
next_url = quote(ring[(pos + 1) % len(ring)], safe="/")
flagged_rels = {m.target for m in marks
if m.shape == "flag" and m.error is None}
flagged_rels = flagged_targets(marks)
# recorded above, before this read: the current item counts as seen
seen = read_seen(booth) & set(ring)
film = [{"name": r, "url": by_rel[r].url, "ordinal": by_rel[r].ordinal,
+10
View File
@@ -131,6 +131,12 @@
small, blurred if the item is. The standalone marks page has no item
records, so it keeps the list, in `(created, id)` order. #}
{% if tray is defined and tray %}
{# In the lightbox the tray and the notes FOLD on a narrow screen (R2 C5):
a closed <details>, which base.html shows open-and-summary-less above
1000px with no script. Below it, the question sits above the set and the
tray and notes are one tap away instead of burying it. #}
<details class="v-fold">
<summary class="v-fold-head">✔ flagged · {{ tray|length }}</summary>
<article class="mark mark-flags" id="mark-flags">
<header class="mark-head">
<span class="mark-state mark-state-flag">✔ flagged</span>
@@ -144,6 +150,7 @@
{% endfor %}
</div>
</article>
</details>
{% elif tray is not defined and flags %}
<article class="mark mark-flags" id="mark-flags">
<header class="mark-head">
@@ -158,6 +165,8 @@
</article>
{% endif %}
{% set fold_notes = tray is defined and notes %}
{% if fold_notes %}<details class="v-fold"><summary class="v-fold-head">notes · {{ notes|length }}</summary>{% endif %}
{% for a in notes %}
<article class="mark mark-note" id="mark-{{ a.id }}">
<header class="mark-head">
@@ -175,6 +184,7 @@
<pre class="mark-text">{{ a.text }}</pre>
</article>
{% endfor %}
{% if fold_notes %}</details>{% endif %}
{# The operator volunteering a remark, which before marks had no mechanism at
+39 -1
View File
@@ -396,6 +396,22 @@
.lightbox{grid-template-columns:1fr;grid-template-areas:"verdict" "set"}
.verdict{position:static;max-height:none}
}
/* The fold (C5): a CLOSED <details> in the markup. Wide, CSS alone shows its
content and hides its summary — `::details-content` is the part of a
details element that closing hides — so nothing is folded where there is
room. Narrow, it stays closed: the question above the set is not buried
under the tray and the notes, which are one tap away. No script. */
.v-fold{display:flex;flex-direction:column;gap:var(--space-3)}
.v-fold-head{cursor:pointer;list-style:none;padding:8px 14px;border:1px solid var(--border-default);
border-radius:var(--radius-lg);background:var(--surface-card);font-family:var(--font-mono);
font-size:var(--size-micro);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)}
.v-fold-head::-webkit-details-marker{display:none}
.v-fold-head::before{content:"▸ ";color:var(--text-muted)}
.v-fold[open] > .v-fold-head::before{content:"▾ "}
@media (min-width:1001px){
.verdict .v-fold > .v-fold-head{display:none}
.verdict .v-fold::details-content{content-visibility:visible;display:contents}
}
/* the flag tray: the flagged items in set order, the originals shown small */
.tray{display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:6px;padding:12px 14px}
.tray-item{position:relative;display:block;aspect-ratio:1;border-radius:var(--radius-md);overflow:hidden;
@@ -419,6 +435,10 @@
.grp-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)}
.grp-key{color:var(--text-heading);font-weight:600}
.grp-n{color:var(--text-muted)}
/* a region the fresh page no longer has (C3): kept in place, visibly not current */
.is-stale{opacity:.45;filter:grayscale(.6)}
/* a region boundary that must not change layout */
.region-wrap{display:contents}
.status{margin:0 0 12px;padding:8px 12px;border-radius:var(--radius-md);background:var(--warning-soft);
color:var(--warning-text);font-size:var(--size-sm)}
@@ -836,6 +856,15 @@
['revealed', 'is-closed'].forEach(function (c) {
if (oldEl.classList.contains(c)) newEl.classList.add(c);
});
/* A disclosure the reader opened or closed stays that way: the server
renders its default, the reader's choice is client state. */
var newDetails = newEl.querySelectorAll('details');
oldEl.querySelectorAll('details').forEach(function (d, i) {
/* ...except the one holding the form just sent: an answered pick's
form comes back folded on purpose, showing the recorded answer. */
if (sent && d.contains(sent)) return;
if (newDetails[i]) newDetails[i].open = d.open;
});
/* An unsaved DRAFT survives a swap it was not part of: a note half-typed
on one tile must not vanish because a flag landed on another. The
form that was just sent is the exception — its field is supposed to
@@ -860,6 +889,13 @@
var node = document.importNode(next, true);
carry(el, node, sent);
el.replaceWith(node);
} else {
/* ABSENT from the fresh page — a tile a filter no longer matches,
say, after un-flagging under ?filter=flagged. Left in place, never
deleted (deleting would shift every tile after it under the
reader's eye), and marked stale so it does not pass for current.
The next navigation drops it. */
el.classList.add('is-stale');
}
});
document.dispatchEvent(new CustomEvent('booth:swapped'));
@@ -880,8 +916,10 @@
if (!r.ok) throw new Error('status ' + r.status);
return r.text();
}).then(function (html) { swap(html, form); }).catch(function () {
/* Said, then reloaded after a beat, so the words are readable rather
than a flash before the page goes. */
say('Could not save in place — reloading to show what was saved.');
window.location.reload();
setTimeout(function () { window.location.reload(); }, 900);
});
});
})();
+14 -3
View File
@@ -73,7 +73,9 @@
{% else %}
<h1>{{ name }}</h1>
{% endif %}
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span>
{# The open count and the lifetime line depend on marks, so they are a region
(R2 C3): answering the last pick in place must not leave "1 open" behind. #}
<span class="region-wrap" data-region="booth-status"><span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span></span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
{{ provenance(manifest) }}
{# A durable multi-writer board gets no one-click wipe — same rule as the
@@ -119,9 +121,14 @@
{# R2 C5: on a GALLERY booth the panel moves into the verdict aside beside the
set (below). It renders up here only where there is no set to sit beside —
a board, or a booth with marks and nothing to show. #}
{% set lightbox = all_items and not board %}
{# `is_board`, not `board`: PAGE IDENTITY, not page content — the lesson the
bench panel already learned. `board` is the parsed rows, empty for a
links.md with none, and a board with an image in it must still be a board. #}
{% set lightbox = all_items and not is_board %}
{% if (marks or not board) and not lightbox %}
<div class="marks-panel" data-region="marks-panel">
{% include "_marks.html" %}
</div>
{% endif %}
{# THE BENCH REGISTRY — BLOCK LEVEL, and that placement is load-bearing.
@@ -266,11 +273,13 @@
(the question above the work), and on a wide one the grid areas in
base.html put the aside on the right. Placement, not order — nothing in an
ordered collection moves. #}
{% if lightbox %}
<div class="lightbox">
<aside class="verdict" data-region="verdict" aria-label="your verdict">
{% include "_marks.html" %}
</aside>
<div class="lb-set">
{% endif %}
{# `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. #}
@@ -319,7 +328,7 @@
{# R2 C5: an inline header before each group's FIRST tile, only when the
rail thinks grouping is informative. A <div> spanning the grid, never a
figure.item, so the keyboard and the order check are blind to it. #}
{% if rail.groups and it.group and (loop.first or loop.previtem.group != it.group) %}
{% if inline_groups and it.group and (loop.first or loop.previtem.group != it.group) %}
<div class="grp-head" aria-hidden="true"><span class="grp-key">{{ it.group }}</span> <span class="grp-n">{{ group_n.get(it.group, '') }}</span></div>
{% endif %}
{% if it.doc and it.rendered is not none %}
@@ -406,8 +415,10 @@
{% endif %}
{% endfor %}
</div>
{% if lightbox %}
</div>{# .lb-set #}
</div>{# .lightbox #}
{% endif %}
{% endif %}
{% if items %}