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:
+48
-12
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user