diff --git a/booth/app.py b/booth/app.py index 0b8d79b..c1d2905 100644 --- a/booth/app.py +++ b/booth/app.py @@ -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, diff --git a/booth/templates/_marks.html b/booth/templates/_marks.html index 4792bf8..855e4f7 100644 --- a/booth/templates/_marks.html +++ b/booth/templates/_marks.html @@ -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
, 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. #} +
+ ✔ flagged · {{ tray|length }}
✔ flagged @@ -144,6 +150,7 @@ {% endfor %}
+
{% elif tray is not defined and flags %}
@@ -158,6 +165,8 @@
{% endif %} +{% set fold_notes = tray is defined and notes %} +{% if fold_notes %}
notes · {{ notes|length }}{% endif %} {% for a in notes %}
@@ -175,6 +184,7 @@
{{ a.text }}
{% endfor %} +{% if fold_notes %}
{% endif %} {# The operator volunteering a remark, which before marks had no mechanism at diff --git a/booth/templates/base.html b/booth/templates/base.html index d16007c..f83745c 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -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
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); }); }); })(); diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 0a4f251..8599281 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -73,7 +73,9 @@ {% else %}

{{ name }}

{% endif %} - {% if uploaded %}⬆ pickup {% 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 %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %} + {# 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. #} + {% if uploaded %}⬆ pickup {% 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 %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %} {% if items %}⬇ zip{% 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 %} +
{% include "_marks.html" %} +
{% 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 %}