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 %}
+70 -19
View File
@@ -103,7 +103,7 @@ no route derives it.
only. A booth mixing images and audio now rings through both, in set order.
- `image_chain` stays for its callers and tests.
- **`SEEN_FILE = ".seen"`**: one rel per line, same shape as `.blurred`.
- Written by `record_seen(booth, rel)` from the review route, below the 404s
- Written by `record_seen(booth, rel, items)` from the review route, below the 404s
and gated on the item record — the same gate `record_view` has.
- Each write rewrites the whole file: the previous set plus `rel`, minus
rels no longer in `booth_items`, sorted. It is deduplicated and pruned, so
@@ -121,6 +121,8 @@ no route derives it.
- NEVER RAISES, like `record_view`: failing to record a look costs the
marker, not the page.
- `read_seen(booth) -> set[str]` is lenient, like `read_blurred`.
- `items` is the route's own `booth_items` result. It is passed in so that the
prune ("minus rels no longer in `booth_items`") costs no second walk.
- **Seen is UI state, not judgment.** It is not exposed in `marks.json` and it
holds nothing.
- It adds no lifetime RULE. Being a dotfile, its write does move
@@ -129,14 +131,17 @@ no route derives it.
### C3 — in-place judgment (app.py, base.html)
**`wants_json(request) -> bool`** is True **only** when the `Accept` header,
split on commas, contains an entry whose media type, parameters stripped, is
**`wants_json(accept: str | None) -> bool`** takes the raw `Accept` header, so
it is a pure function a test can call directly. It is True **only** when the
header, split on commas, contains an entry whose media type, parameters stripped, is
exactly `application/json` and whose q-value is absent or greater than 0.
- Absent, empty, `*/*` or `application/*` → False.
- `application/json;q=0` → False. A client that explicitly refuses JSON gets
the redirect.
- A near miss such as `application/jsonx` → False.
- **Every entry is parsed before anything is decided.** One unparseable
entry anywhere, before or after a good one, makes the whole header False.
- Any header that fails to parse → False.
- **It fails toward the 303.**
@@ -167,18 +172,35 @@ today's zoom flag form carries no `back`, so it lands on the gallery.
2. On 204, GET the current URL and replace **every** element carrying
`data-region="<id>"` with the same-id element from the response.
- The rule is "every region whose content can depend on marks is a
region". On the lightbox that means the verdict aside, each tile, and the
rail (its filter counts change when you flag). On the review it means the
rail, the filmstrip and the tape.
region".
- On the lightbox: the verdict aside, each tile, the rail (its filter
counts change when you flag), and the header's open count and lifetime
line (`booth-status`).
- On a booth with marks but no set: the panel (`marks-panel`).
- On the review: the rail, the filmstrip and the tape.
- The stage is never a region: replacing it would restart a playing video
or audio track.
- A region absent from the response is left alone and never deleted.
Deleting it would shift every tile after it under the reader's eye. It
is marked `is-stale` so it does not pass for current: un-flagging under
`?filter=flagged` is the case. The next navigation drops it.
- The swap also carries the per-viewer state a reload would have reset
but an in-place save must not:
- live media whose src is unchanged;
- a revealed blur;
- a closed doc;
- disclosures the reader opened or closed;
- unsaved drafts.
The form just sent is the exception: its field comes back empty, and its
disclosure comes back folded.
3. **The script never re-POSTs.** A retry after a lost response would re-apply
the judgment: a duplicate note, or a re-dated answer.
- On a non-204 HTTP response, or a network failure, it writes a fixed
message into the page's server-rendered status element
(`data-region="status"`, via textContent), then reloads the page with a
GET, so what you see is the server's truth.
(`data-region="status"`, via textContent). After a beat (0.9 s, so the
words can be read) it reloads the page with a GET, so what you see is the
server's truth.
- The one case where a non-JS submit happens is a script that cannot run at
all. That is the plain form.
@@ -196,9 +218,20 @@ rule — a second renderer in JavaScript would be the same bug in a new language
legacy-import stamp, sort wrong as text.
- An unparseable stamp sorts AFTER every parseable one, and name breaks the
tie.
- **`flags`**: the count of flag marks, shown on every Desk row that has any.
- **`landed_at`**: the newest mtime among the booth's NON-DOT entries — its
content. **Deliberately not `_newest_mtime`** (INV-5).
- **`flags`**: the number of items carrying a READABLE flag mark, shown on
every Desk row that has any. `flagged_targets(marks)` is the ONE flag
predicate. The Desk, the tray, the filmstrip, the tape and the review button
all read it, and an unreadable flag entry counts nowhere.
- **`landed_at`**: the newest mtime among the booth's CONTENT — its REGULAR
FILES with no dot-component in their path. **Deliberately not
`_newest_mtime`** (INV-5). Three refinements, each load-bearing:
- **Files only, never directories.** Creating any dotfile (`.viewed`, the
marks file's temp-and-replace) bumps the booth directory's own mtime, so
counting directories would make the flag you set after looking read as a
delivery.
- **An empty booth landed at 0.0.**
- **An unreadable booth reads as NOW.** It is shown as new rather than
hidden as old.
- **`viewed_at`**: the mtime of `.viewed`, or None.
- **`preview`**: up to 4 image items as `(url, blurred)`, first four in item
order. A blurred one renders blurred, the same rule as the cover.
@@ -217,8 +250,10 @@ rule — a second renderer in JavaScript would be the same bug in a new language
(errored picks are not open). Somebody has to fix it, so it must not hide
in 'everything else'. It renders with the existing "marks unreadable"
lifetime line.
- Ordered by `(open_since, name)`, oldest question first. Unreadable booths
have no `open_since` and sort after every booth that has one.
- Ordered by `(open_since, name)`, oldest question first. A booth held
`unreadable` has no `open_since` — even when a readable pick sits beside
the damage, because the damage is the thing to fix — and sorts after every
booth that has one.
2. **New since you looked** — `not in_needs_you and (viewed_at is None or
landed_at > viewed_at)`. Ordered by `(-landed_at, name)`, newest first.
3. **Everything else** — in `list_booths`' own existing order: `(mtime, name)`
@@ -260,6 +295,11 @@ unchanged) remain on every row.
**verdict aside** on the right (`position:sticky`, `data-region="verdict"`).
Under 1000px the aside stacks above the set, with its flags and notes
collapsed as `<details>`, which needs no script.
- The markup is a CLOSED `<details>`.
- Above 1000px, CSS alone shows its content (`::details-content`) and hides
its summary, so nothing is folded where there is room.
- A browser without `::details-content` shows the fold at every width: one
tap, never hidden.
- **Board booths are unchanged.** Anything with `links.md` keeps today's
single column.
- **The aside holds, top to bottom:**
@@ -274,8 +314,14 @@ unchanged) remain on every row.
The order is total with no tie-break, because rels are unique.
- **The rail stays.** Same element, same `.rail` class (booth.html's cursor
and base.html's `--rail-h` script both read it), same filter hrefs, same
group anchors. When `rail.groups` is non-empty, the grid additionally
renders an inline group header before each group's first tile. It is a
group anchors. When `rail.groups` is non-empty AND every group is one
contiguous run in the rendered order, the grid additionally renders an
inline group header before each group's first tile.
- Groups come from basenames and the order from full paths, so groups can
interleave (`d1/aa`, `d1/bb`, `d2/aa`).
- A header would then either repeat or file an item under the wrong group,
so interleaved groups get no inline headers. The rail's jump links are
unaffected. It is a
`<div>` spanning the grid, never a `figure.item`, so the keyboard and the
order check are blind to it by construction.
- **Every tile shows `#NN`** (its ordinal, zero-padded to the set's width).
@@ -301,7 +347,9 @@ This applies to image, video and audio items. Docs keep `doc.html`.
- `K of M`, where K is its position in `review_chain` and M is the length
of `review_chain`. The tape's "N of M seen" uses the SAME M, and N counts
`.seen` ∩ `review_chain`;
- its position within its group, when the booth has groups;
- its position within its group, when the review RING spans two or more
groups (the gallery rail's own rule: one group for everything says
nothing);
- the caption;
- the flag form (`back=view`);
- notes and the add-note form (`back=view`);
@@ -312,15 +360,18 @@ This applies to image, video and audio items. Docs keep `doc.html`.
- **The tape** (B's device) is one segment per `review_chain` item: seen /
flagged / current, plus "N of M seen".
- **The end of the set** is not a separate page. On the last ring item the
rail adds a summary block: seen count, flag tray, and every open booth-level
pick answerable in place.
rail adds a summary block: the seen count, the flag tray, and EVERY OTHER
open pick, answerable in place.
- That includes picks targeting other items, not only booth-level ones: the
end of the set is where the remaining questions get cleared.
- Before the last item, the other picks are a count and a link.
- **Keys** (additive). **Every** key here, new and old, is ignored while focus
is in an `input`, `textarea`, `select` or `contenteditable`, the same
`isEditable` guard view.html carries today, so F never fires mid-note:
| key | action |
|---|---|
| ← → and Space | move |
| ← → and Space | move. Shift+Space moves back. Space is left to a focused `<video>`/`<audio>` player, whose own play key it is |
| F | flag |
| N | focus the note |
| Esc | back to the grid, at `#item-<url>` so the grid scrolls to where you were |
+119 -4
View File
@@ -412,12 +412,16 @@ def test_a_booth_without_images_shows_its_kind_instead(tmp_path):
# ---- C5: the lightbox ---------------------------------------------------------
def _region(body: str, rid: str) -> str:
"""The element carrying data-region=rid, through its matching close tag
(same-name nesting counted, so a region holding spans or divs is whole)."""
m = re.search(r'<(\w+)[^>]*data-region="%s"[^>]*>' % re.escape(rid), body)
assert m, f"no region {rid}"
tag = m.group(1)
# regions in these templates do not nest a same-named tag inside themselves
end = body.index(f"</{tag}>", m.end())
return body[m.start():end]
tag, depth, pos = m.group(1), 1, m.end()
for t in re.finditer(r"<(/?)%s\b[^>]*>" % tag, body[pos:]):
depth += -1 if t.group(1) else 1
if depth == 0:
return body[m.start():pos + t.end()]
raise AssertionError(f"region {rid} never closes")
def test_the_verdict_sits_beside_the_set_on_a_gallery_booth(tmp_path):
@@ -558,3 +562,114 @@ def test_no_emblem_in_the_chrome(tmp_path):
m = re.search(r'<header class="topbar">.*?</header>', body, re.S)
if m:
assert "<img" not in m.group(0) and "<svg" not in m.group(0), path
# ---- fixups from the heid code-review panel (round "Wren") --------------------
@pytest.mark.parametrize("accept,want", [
("application/json", True),
("application/json;q=0.5", True),
("application/json;q=abc", False), # malformed q alone
("application/json, application/json;q=broken", False), # malformed AFTER a good one
("application/json;q=broken, application/json", False),
("application/json, text/plain;q=nope", False), # any unparseable entry
])
def test_wants_json_fails_closed_on_any_unparseable_entry(accept, want):
"""C3: any header that fails to parse is False, whatever order its entries
come in. The first cut returned True as soon as it met a good JSON entry,
so a malformed one after it was never read (Wren W3, 3/4)."""
from booth.app import wants_json
assert wants_json(accept) is want
def test_a_board_booth_keeps_its_single_column_even_with_media_in_it(tmp_path):
"""C5: ANYTHING with links.md is a board — page identity, not page content
(the lesson `is_board` already carries). A board with an image and a
links.md that parses to no rows must not get the lightbox (Wren W2, 3/4)."""
_booth(tmp_path, "links", {"links.md": b"just prose, no rows\n", "a.png": PNG})
body = _client(tmp_path).get("/b/links/").text
assert 'class="lightbox"' not in body and 'data-region="verdict"' not in body
def test_the_header_count_and_lifetime_line_are_a_region_too(tmp_path):
"""Answering the last open pick in place must not leave "1 open" and
"held until answered" stale in the header (Wren, hulda) — they depend on
marks, so by C3's rule they are a region."""
from booth.marks import declare_pick
b = _booth(tmp_path, "g", {"a.png": PNG})
declare_pick(b, "q", {"prompt": "?", "options": ["x", "y"]})
status = _region(_client(tmp_path).get("/b/g/").text, "booth-status")
assert "1 open" in status and "held until answered" in status
def test_a_booth_with_marks_and_no_items_keeps_its_panel_in_a_region(tmp_path):
"""No set, so no lightbox — but the panel's forms are in-place, so the
panel must be a region or a note written there shows nowhere (Wren, hulda)."""
from booth.marks import write_note
b = _booth(tmp_path, "g", {})
write_note(b, None, "only a note")
body = _client(tmp_path).get("/b/g/").text
assert "only a note" in _region(body, "marks-panel")
def test_interleaved_groups_get_no_inline_headers(tmp_path):
"""Groups come from basenames, the order from full paths, so groups can
interleave: d1/aa, d1/bb, d2/aa, d2/bb. Re-printing 'aa' and 'bb' would
claim runs that are not there, and one header per group would file d2/aa
under 'bb'. Headers render only when every group is one contiguous run
(Wren, hulda). The rail's jump links are unaffected."""
_booth(tmp_path, "g", {"d1/aa-1.png": PNG, "d1/bb-1.png": PNG,
"d2/aa-2.png": PNG, "d2/bb-2.png": PNG})
body = _client(tmp_path).get("/b/g/").text
assert 'class="grp-head"' not in body
assert 'class="rail-g"' in body
def test_a_booth_with_damaged_marks_sorts_after_every_dated_question(tmp_path):
"""Mixed damage: one readable open pick (the OLDEST stamp here) plus one
entry that cannot be read. The booth is held 'unreadable', and the
contract puts unreadable booths after every booth with a dated question —
the damage is the thing to fix, not the age of the pick beside it (Wren,
groa)."""
import json
from booth.marks import declare_pick
mixed = _booth(tmp_path, "mixed", {"a.png": PNG})
clean = _booth(tmp_path, "clean", {"a.png": PNG})
declare_pick(mixed, "q", {"prompt": "?", "options": ["x", "y"]})
declare_pick(clean, "q", {"prompt": "?", "options": ["x", "y"]})
_set_created(mixed, "q", "2026-01-01T00:00:00+00:00")
_set_created(clean, "q", "2026-09-01T00:00:00+00:00")
doc = json.loads((mixed / ".marks.json").read_text())
doc["marks"].append({"id": "bad", "shape": "note", "created": 7, "text": "x"})
(mixed / ".marks.json").write_text(json.dumps(doc))
body = _client(tmp_path).get("/").text
assert _desk(body)["needs"] == ["clean", "mixed"]
def test_one_flag_predicate_everywhere_and_a_damaged_flag_counts_nowhere(tmp_path):
"""The Desk count, the tray, the filmstrip and the review button all read
ONE predicate: a readable flag mark on the item. An unreadable flag entry
is judgment we cannot see, and must not inflate a count it cannot be shown
in (Wren W4, groa/kimi)."""
import json
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
set_flag(b, "a.png", True)
doc = json.loads((b / ".marks.json").read_text())
doc["marks"].append({"id": "flag:b.png", "shape": "flag", "target": "b.png", "created": 9})
(b / ".marks.json").write_text(json.dumps(doc))
c = _client(tmp_path)
row = re.search(r'data-booth="g".*?</article>', c.get("/").text, re.S).group(0)
assert "1 flagged" in row
review = c.get("/b/g/view?f=b.png").text
assert "○ flag" in _region(review, "rail")
assert re.findall(r'class="film-f([^"]*)"\s+href="\?f=([^"]+)"', review)[1][0].split() == ["is-current"]
def test_a_full_size_look_also_counts_as_looking_at_the_booth(tmp_path):
"""C2: the review route calls record_view as well as record_seen, so the
Desk's "new since you looked" clears when the booth is reviewed at full
size, not only when its grid is opened (Wren, groa: untested)."""
b = _booth(tmp_path, "g", {"a.png": PNG})
assert not (b / ".viewed").exists()
_client(tmp_path).get("/b/g/view?f=a.png")
assert (b / ".viewed").exists() and (b / ".seen").exists()
+104 -2
View File
@@ -107,13 +107,57 @@ def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
page.goto(f"{base}/b/g/", wait_until="networkidle")
(b / ".marks.json").write_text("{damaged")
with page.expect_navigation(timeout=10000):
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
# the reader is TOLD before the page goes (Wren, hulda: nothing asserted it)
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
said = page.locator('[data-region="status"]').inner_text()
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1500) # past the reload
page.close()
assert "Could not save in place" in said
assert len(posts) == 1, f"re-POSTed: {posts}"
def test_a_lost_response_after_a_landed_write_is_never_retried(browser, live):
"""The case the never-re-POST rule exists for: the server WROTE the note,
then the response was lost. A retry would write it twice. The route lets
the request reach the server and then drops the reply."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/", wait_until="networkidle")
def drop_after_write(route):
route.fetch() # the write lands
route.abort() # ...and the browser never hears back
page.route("**/b/g/note", drop_after_write)
page.locator(".verdict .mark-add textarea").fill("exactly once")
page.locator(".verdict .mark-add button").click()
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
page.wait_for_timeout(1500)
page.close()
notes = [m for m in marks_for(b) if m.shape == "note" and m.text == "exactly once"]
assert len(notes) == 1, f"written {len(notes)} times"
def test_a_tile_the_fresh_page_no_longer_has_stays_put_marked_stale(browser, live):
"""Un-flag under ?filter=flagged: the fresh page has no such tile. It is
left where it is (nothing shifts under the reader) and marked stale."""
from booth.marks import set_flag
base, root = live
b = _set(root, 3)
set_flag(b, "01.png", True)
set_flag(b, "02.png", True)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/?filter=flagged", wait_until="networkidle")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
page.wait_for_selector('figure.item.is-stale[data-item="01.png"]', timeout=10000)
tiles = page.eval_on_selector_all("figure.item", "els => els.map(e => e.dataset.item)")
page.close()
assert tiles == ["01.png", "02.png"]
def test_the_review_keys_judge_in_place_and_stay_out_of_the_note(browser, live):
"""At full size: F typed into the note is a letter; F outside it flags IN
PLACE (the filmstrip underline and the tape catch up, no reload); Space
@@ -141,10 +185,68 @@ def test_the_review_keys_judge_in_place_and_stay_out_of_the_note(browser, live):
assert state["film"] == ["#2"]
assert state["draft"] == "fff", "an unsaved note must survive a swap it was not part of"
page.keyboard.press("n")
focused = page.evaluate("document.activeElement.id")
assert focused == "vnote-text", "N focuses the note"
page.locator(".vr-where").click()
with page.expect_navigation():
page.keyboard.press(" ")
assert page.url.endswith("/b/g/view?f=03.png")
with page.expect_navigation():
page.keyboard.press("ArrowRight")
assert page.url.endswith("/b/g/view?f=04.png")
with page.expect_navigation():
page.keyboard.press("ArrowLeft")
assert page.url.endswith("/b/g/view?f=03.png")
with page.expect_navigation():
page.keyboard.press("Escape")
assert page.url.endswith("/b/g/#item-03.png")
page.close()
def test_the_stage_survives_an_in_place_save_and_a_focused_radio_keeps_f(browser, live):
"""The stage is never a region: the same node must still be on the page
after a flag lands (a playing track would restart otherwise). And F while
a radio — an <input> — has focus is not a flag."""
from booth.marks import declare_pick
base, root = live
b = _set(root, 2)
declare_pick(b, "q", {"prompt": "Sharp?", "options": ["yes", "no"]}, target="01.png")
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
page.evaluate("document.getElementById('vstage').__mark = 1")
page.locator('.vrail input[type=radio]').first.focus()
page.keyboard.press("f")
page.wait_for_timeout(600)
assert page.locator(".vflag-btn.is-flagged").count() == 0, "F in a radio is not a flag"
page.locator(".vr-where").click()
page.keyboard.press("f")
page.wait_for_selector(".vflag-btn.is-flagged", timeout=10000)
same = page.evaluate("document.getElementById('vstage').__mark === 1")
page.close()
assert same, "the stage was replaced by the swap"
@pytest.mark.parametrize("js", [True, False])
def test_on_a_narrow_screen_flags_and_notes_fold_and_on_a_wide_one_they_show(browser, live, js):
"""C5 (Wren W1, 4/4): under 1000px the verdict stacks ABOVE the set, and
its flags and notes fold into <details> so the question is not buried —
with no script. Wide, they are simply shown. Checked with JS on and off."""
from booth.marks import set_flag, write_note
base, root = live
b = _set(root, 3)
set_flag(b, "02.png", True)
write_note(b, None, "a booth note")
for width, shown in ((390, False), (1400, True)):
ctx = browser.new_context(viewport={"width": width, "height": 900}, java_script_enabled=js)
page = ctx.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
tray = page.locator(".verdict .tray-item").first
note = page.locator(".verdict .mark-text", has_text="a booth note")
assert tray.is_visible() is shown, (width, js, "tray")
assert note.is_visible() is shown, (width, js, "note")
if not shown: # folded, but one tap away
page.locator(".verdict summary.v-fold-head").first.click()
assert tray.is_visible()
ctx.close()