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 %}
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user