- On a gallery booth the marks panel moves into a sticky verdict aside beside the set. The aside comes first in the document, so a narrow screen stacks the question above the work; grid areas place it on the right when wide. Nothing in an ordered collection moves. Boards are unchanged. - The flag tray lists flagged items by tile number: the declared change from the panel list's (created, id). The standalone marks page keeps the list. - Inline group headers are divs, never figure.item. - Every mark-dependent element is a data-region: the verdict, each tile, the rail's filter counts. There is also a server-rendered status line. - The in-place script (base.html) POSTs with an explicit JSON Accept, then on 204 swaps every region from a fresh GET. Live media and per-viewer view state are carried across the swap, so there is no layout jolt and no stopped track. It never re-POSTs: on failure it says so and reloads. Tile controls re-bind after a swap, and the grid cursor survives it. - The `n` key opens the tile's closed note disclosure before focusing it. - test_embed_browser's keyboard-flag test expected a navigation, which is the defect R2 removes. It is updated as declared in the contract, and tightened: a window marker must survive, proving no reload. Browser tests: flag in place, with no reload and no scroll jump, and the tile, tray and rail count all updated; and a failed save that reloads without re-POSTing. Two mutations turn them red (no carry, no rail region). 700 passed.
586 lines
31 KiB
HTML
586 lines
31 KiB
HTML
{% extends "base.html" %}
|
||
{% from "_provenance.html" import provenance %}
|
||
{% from "_lifetime.html" import lifetime %}
|
||
{# The blur toggle, defined ONCE. There are three item branches in this file
|
||
(doc / media / other) and the first cut of this feature patched only one of
|
||
them, so docs rendered with no control at all. A macro makes "patched two of
|
||
three" impossible rather than merely unlikely. #}
|
||
{% macro blurtoggle(name_url, it, cls='') -%}
|
||
<form class="blurtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/blur">
|
||
<input type="hidden" name="f" value="{{ it.name }}">
|
||
<input type="hidden" name="on" value="{{ '0' if it.blurred else '1' }}">
|
||
<button title="{{ 'un-blur this item' if it.blurred else 'blur this item — cosmetic only, the file is still served' }}"
|
||
aria-label="{{ 'un-blur' if it.blurred else 'blur' }} {{ it.name }}"
|
||
>{{ '◉ blurred' if it.blurred else '◌ blur' }}</button>
|
||
</form>
|
||
{%- endmacro %}
|
||
|
||
{# The per-item MARK controls: flag (the operator pointing at this one) and a
|
||
note field. Same macro discipline as blurtoggle above — three item branches,
|
||
one definition. `marks` here is THIS item's marks, from item_marks. #}
|
||
{% macro markcontrols(name_url, it, marks, cls='') -%}
|
||
{% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %}
|
||
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag" data-inplace>
|
||
<input type="hidden" name="target" value="{{ it.name }}">
|
||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||
<button title="{{ 'un-flag this item' if flagged else 'flag this one — the session that posted it can read the selection' }}"
|
||
aria-label="{{ 'un-flag' if flagged else 'flag' }} {{ it.name }}"
|
||
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
|
||
</form>
|
||
{%- endmacro %}
|
||
|
||
{# An item's notes, rendered BESIDE the artifact — the 2026-09-09 ruling that a
|
||
judgment belongs with the thing it is about, applied to notes as well as
|
||
picks. The add-field is a <details> so 270 tiles do not each carry an open
|
||
textarea. #}
|
||
{% macro marknotes(name_url, it, marks) -%}
|
||
{% for m in marks if m.shape == 'note' %}
|
||
<div class="item-note" id="mark-{{ m.id }}">
|
||
<pre>{{ m.text }}</pre>
|
||
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||
<button class="mark-x" title="withdraw this note">×</button>
|
||
</form>
|
||
</div>
|
||
{% endfor %}
|
||
<details class="item-addnote">
|
||
<summary>+ note</summary>
|
||
<form method="post" action="/b/{{ name_url }}/note" data-inplace>
|
||
<input type="hidden" name="target" value="{{ it.name }}">
|
||
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||
<button type="submit">Add</button>
|
||
</form>
|
||
</details>
|
||
{%- endmacro %}
|
||
|
||
{# R2 C1: an item's number in the WHOLE set, zero-padded to the set's width so
|
||
a column of them lines up. Width reads `all_items`, never the filtered list:
|
||
a filter must not change how a number is written any more than which. #}
|
||
{% macro ordinal(it) -%}
|
||
<span class="ord" data-ordinal="{{ it.ordinal }}">#{{ "%0*d"|format((all_items|length|string|length), it.ordinal) }}</span>
|
||
{%- endmacro %}
|
||
|
||
{% block title %}{{ name }} · The Booth{% endblock %}
|
||
{% block content %}
|
||
<div class="boothhead">
|
||
<a class="back" href="/">‹ all booths</a>
|
||
{# The manifest's TITLE is the display name; the directory name stays visible
|
||
beside it because that is the identity the operator navigates by and refers
|
||
to positionally, and losing it would be losing the thing the URL says.
|
||
Index cards keep the directory name alone for the same reason. #}
|
||
{% if manifest and not manifest.error and manifest.title and manifest.title != name %}
|
||
<h1>{{ manifest.title }} <span class="h1-slug">{{ name }}</span></h1>
|
||
{% 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>
|
||
{% 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
|
||
kept lane on the index. Remove rows with the per-row ×, or release the
|
||
board from the index and wipe it from there. #}
|
||
{# Promote or release without going back to the index. `next` keeps you on
|
||
this page instead of bouncing you to /. #}
|
||
{% if kept %}
|
||
<form class="keep-lg" method="post" action="/b/{{ name_url }}/unkeep">
|
||
<input type="hidden" name="next" value="/b/{{ name_url }}/">
|
||
<button title="release — rejoins the TTL sweep">★ kept — release</button>
|
||
</form>
|
||
{% else %}
|
||
<form class="keep-lg" method="post" action="/b/{{ name_url }}/keep">
|
||
<input type="hidden" name="next" value="/b/{{ name_url }}/">
|
||
<button title="keep — exempt from the TTL sweep">☆ keep</button>
|
||
</form>
|
||
{% endif %}
|
||
{% if not board %}
|
||
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
|
||
onsubmit="return confirm('Wipe this booth now?')">
|
||
<button>Wipe now</button>
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if uploaded %}
|
||
<div class="pickup-note">
|
||
📦 Pickup <code>{{ name }}</code>
|
||
<button type="button" class="copy-btn" data-copy="{{ name }}" title="copy id to clipboard">⧉ copy</button>
|
||
— download files below, or on nh3-dev grab <code>~/booth-data/{{ name }}/</code>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# The marks panel: the session's questions, the operator's notes, and the way
|
||
back to the flagged items. Always rendered on a gallery booth — the add-note
|
||
field is a control, not a result, so it has to be there before the first
|
||
mark exists. #}
|
||
{# `marks or not board`: the standing link board renders as a board rather than
|
||
a gallery, and the add-note control would be noise on it — but the
|
||
suppression was unconditional, so a pick declared on a booth that happens to
|
||
carry a links.md had no form to answer it and nothing said so. #}
|
||
{# 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 %}
|
||
{% if (marks or not board) and not lightbox %}
|
||
{% include "_marks.html" %}
|
||
{% endif %}
|
||
|
||
{# THE BENCH REGISTRY — BLOCK LEVEL, and that placement is load-bearing.
|
||
This <div> spent one commit nested inside the `<span class="sub">` of the
|
||
booth header, because the insertion matched the FIRST `{% if board %}` in
|
||
the file rather than the block-level one. A <div> inside a <span> is
|
||
invalid HTML: the parser closes the span implicitly and hoists the div
|
||
out, orphaning the rest of the sub-line. Three of four cold bug-hunt arms
|
||
found it and the seat confirmed it in the live document by byte offset.
|
||
Keep this block between the marks panel and the board form. #}
|
||
{% if is_board %}
|
||
{# THE BENCH REGISTRY. A bench is a running thing — jackdaw's current bench,
|
||
talk's current bench, the things that get promoted to Homepage when they
|
||
are fully deployed. NOT a booth (a booth announces itself and is swept) and
|
||
NOT a bookmark (a repo page, a model card — those stay on the board below).
|
||
|
||
Identity is the NORMALIZED URL, so re-announcing a bench updates its row
|
||
instead of appending a fifth. `talk` was on the board five times.
|
||
|
||
ORDER: state (live → promoted → retired), then name, then id as a total
|
||
tie-break so two benches sharing a name cannot swap between renders.
|
||
|
||
The href is `b.url` — the URL AS POSTED — never `b.id`. The id is
|
||
normalized for identity; a server that cares about a trailing slash or a
|
||
case-sensitive path would 404 on it. #}
|
||
<div class="benches">
|
||
<div class="bench-head">
|
||
<span class="bench-title">{{ benches|length }} bench{{ '' if benches|length == 1 else 'es' }}</span>
|
||
<span class="bench-note">a running thing, registered · re-posting updates the row</span>
|
||
</div>
|
||
{% if benches_error %}
|
||
{# DAMAGED AND ABSENT MUST NOT RENDER THE SAME. Only one of them needs a
|
||
human, and the v0.2.2 outage was learned by treating them alike. #}
|
||
<div class="bench-err">the bench registry could not be read: {{ benches_error }}</div>
|
||
{% elif not benches %}
|
||
<div class="bench-empty">no benches registered yet — <code>booth bench add <url> <name></code></div>
|
||
{% endif %}
|
||
{% for b in benches %}
|
||
<div class="bench-row is-{{ b.state }}">
|
||
<span class="bench-state">{{ b.state }}</span>
|
||
<div class="bench-main">
|
||
<a class="bench-link" href="{{ b.url }}" target="_blank" rel="noopener">{{ b.name or b.url }}</a>
|
||
<div class="bench-url">{{ b.url }}</div>
|
||
</div>
|
||
<div class="bench-meta">
|
||
{% if b.owner %}<span class="bench-who">{{ b.owner }}</span>{% endif %}
|
||
{# The date it was REGISTERED, not the date it was last touched: `added`
|
||
survives re-registration and `updated` does not, so `added` is the
|
||
one that answers "how long has this been around". #}
|
||
{% if b.added %}<span class="bench-when">{{ b.added[:10] }}</span>{% endif %}
|
||
</div>
|
||
<form class="bench-acts" method="post" action="/b/{{ name_url }}/bench-state">
|
||
<input type="hidden" name="bench" value="{{ b.id }}">
|
||
{% for s in ("live", "promoted", "retired") %}
|
||
{% if s != b.state %}
|
||
<button type="submit" name="state" value="{{ s }}" class="bench-to">{{ s }}</button>
|
||
{% endif %}
|
||
{% endfor %}
|
||
<button type="submit" class="bench-rm" formaction="/b/{{ name_url }}/bench-remove"
|
||
title="remove this bench">×</button>
|
||
</form>
|
||
</div>
|
||
{% endfor %}
|
||
<form class="bench-add" method="post" action="/b/{{ name_url }}/bench-add">
|
||
<input type="url" name="url" placeholder="https://host:port/" required>
|
||
<input type="text" name="name" placeholder="what it is">
|
||
<button type="submit">register</button>
|
||
</form>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if board %}
|
||
{# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so
|
||
this is the one booth where the useful granularity is the ROW, not the
|
||
folder. Rendered as real UI rather than a markdown blob so a dead link can
|
||
be removed without hand-editing the file — and so provenance (who posted
|
||
it, when) is readable at a glance, which is the whole reason a bare URL
|
||
three days old is useless.
|
||
|
||
ORDER: pinned rows first, then newest-first (order_for_display). Pin a row
|
||
with the ★ so the ones you care about stop scrolling off the bottom.
|
||
|
||
ONE <form>, not one-per-row: checkboxes drive the bulk delete, while the
|
||
per-row × and ★ are submit buttons with their own `formaction`. That keeps
|
||
all three actions in a single form (nested forms are invalid HTML) AND lets
|
||
every one work with JS off — JS only adds select-all and the live count.
|
||
|
||
Every action posts a CONTENT ID, never a row number: another session can
|
||
append between this page rendering and a click, and an index would then hit
|
||
a neighbour. An id matches the row the operator saw, or nothing. #}
|
||
{% set pinned_n = board | selectattr('pinned') | list | length %}
|
||
<form class="board" method="post" action="/b/{{ name_url }}/unlink-many" id="boardform">
|
||
<div class="board-head">
|
||
<label class="board-selall" title="select all"><input type="checkbox" id="board-selall"></label>
|
||
<span class="board-title">{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if pinned_n %} · {{ pinned_n }} pinned{% endif %}</span>
|
||
<span class="board-note">pinned first · newest on top · ★ pins a row · tick rows to delete</span>
|
||
<span class="board-spacer"></span>
|
||
<button type="submit" class="board-del-sel" id="board-del-sel"
|
||
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
|
||
</div>
|
||
{% for e in board %}
|
||
{# DEAD: the row points at a booth that has been swept. 156 of 221 rows.
|
||
MARKED, never removed — removal is the operator ticking the box and using
|
||
the bulk control that was already here. #}
|
||
<div class="board-row{% if e.pinned %} is-pinned{% endif %}{% if e.dead %} board-dead{% endif %}">
|
||
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
|
||
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
|
||
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
|
||
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
|
||
<div class="board-main">
|
||
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
|
||
<div class="board-url">{{ e.url }}{% if e.dead %} <span class="board-dead-tag">booth is gone</span>{% endif %}</div>
|
||
</div>
|
||
<div class="board-meta">
|
||
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
|
||
{% if e.when %}<span class="board-when">{{ e.when }}</span>{% endif %}
|
||
</div>
|
||
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
|
||
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
|
||
name="entry" value="{{ e.id }}" title="remove this link"
|
||
data-desc="{{ e.desc }}" data-url="{{ e.url }}">×</button>
|
||
</div>
|
||
{% endfor %}
|
||
</form>
|
||
{% endif %}
|
||
|
||
{# ⚠ THE RAIL IS GATED ON `all_items`, NOT `items`, AND THAT IS THE WHOLE
|
||
POINT. `items` is the FILTERED list, so gating on it meant a valid filter
|
||
with zero hits removed the rail, the filter links and the only way back to
|
||
`all` — while the empty-booth branch below announced the booth was empty
|
||
with `rail.total` still holding the real count. No recovery without editing
|
||
the address bar, and it failed the same way with JavaScript off, on the
|
||
surface the operator actually reviews on.
|
||
|
||
Found by the heid bug-hunt panel (gróa, 2026-09-22), whose own note called
|
||
it the finding most likely to bite users this week. #}
|
||
{% if not all_items and not board and not marks %}
|
||
<div class="empty">This booth is empty.</div>
|
||
{% elif all_items %}
|
||
{# THE LIGHTBOX (R2 C5). The verdict aside comes FIRST in the document and the
|
||
set second: on a narrow screen that is the stacking the contract wants
|
||
(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. #}
|
||
<div class="lightbox">
|
||
<aside class="verdict" data-region="verdict" aria-label="your verdict">
|
||
{% include "_marks.html" %}
|
||
</aside>
|
||
<div class="lb-set">
|
||
{# `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. #}
|
||
{# THE RAIL. Totals and per-filter counts, as LINKS with a query parameter —
|
||
resolved server-side, so the whole thing works with JavaScript off. The
|
||
gallery is the surface the operator actually reviews on and U3 already
|
||
cost the verbatim path its no-JS operation; this one does not repeat that.
|
||
|
||
ORDER: the declaration order of FILTERS in app.py. A rail is an ordered
|
||
collection and invariant 6 binds to it like any other.
|
||
|
||
THE GROUP ROW is `rail.groups`, which is EMPTY unless grouping is
|
||
informative — see `_groups` in app.py. `{% raw %}{% if rail.groups %}{% endraw %}`
|
||
is therefore the whole guard; the two degenerate cases (one group for
|
||
everything, one group per item) are decided in Python, where they can be
|
||
measured, rather than by a count in a template. #}
|
||
<div class="rail" data-region="filters">
|
||
<span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span>
|
||
{% for f in rail.counts %}
|
||
<a class="rail-f{% if f.key == filter %} on{% endif %}"
|
||
data-filter="{{ f.key }}"
|
||
href="/b/{{ name_url }}/{% if f.key != 'all' %}?filter={{ f.key }}{% endif %}"
|
||
{% if f.key == filter %}aria-current="true"{% endif %}>{{ f.key }} <b>{{ f.n }}</b></a>
|
||
{% endfor %}
|
||
{% if rail.groups %}
|
||
<nav class="rail-groups" aria-label="jump to group">
|
||
{% for g in rail.groups %}
|
||
{# The anchor is the first member's EXISTING tile id, so a group has one
|
||
identity on the page rather than two. Plain fragment links: no JS,
|
||
and the browser's own back button undoes the jump. #}
|
||
<a class="rail-g" data-group="{{ g.key }}"
|
||
href="#{{ g.anchor }}">{{ g.key }} <b>{{ g.n }}</b></a>
|
||
{% endfor %}
|
||
</nav>
|
||
{% endif %}
|
||
</div>
|
||
{% if not items %}
|
||
{# An empty FILTER, not an empty booth. The rail above is still rendered, so
|
||
the way back to `all` is one click. #}
|
||
<div class="empty">No items match the <b>{{ filter }}</b> filter.
|
||
<a href="/b/{{ name_url }}/">show all {{ rail.total }}</a></div>
|
||
{% endif %}
|
||
{% set group_n = {} %}{% for g in rail.groups %}{% set _ = group_n.update({g.key: g.n}) %}{% endfor %}
|
||
<div class="gallery" id="grid" tabindex="-1">
|
||
{% for it in items %}
|
||
{# 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) %}
|
||
<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 %}
|
||
{# Docs render INLINE, collapsible, and closable — not a link to a
|
||
separate page. <details open> is native collapse (works with JS off);
|
||
the ✕ hides the item for the session (JS, progressive enhancement).
|
||
The item spans the full grid width so prose has room to read. #}
|
||
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
|
||
{% if it.blurred %}
|
||
{# Inline docs need this MORE than images, not less: a rendered doc puts
|
||
its text straight on the page, so "blur the picture" logic that skips
|
||
the doc branch leaves the most readable content unblurred. Missed on
|
||
the first pass; caught by a live check, not by the suite. #}
|
||
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
|
||
{% endif %}
|
||
<details class="doc-inline" open>
|
||
<summary class="doc-bar">
|
||
<span class="doc-chevron" aria-hidden="true">▸</span>
|
||
{{ ordinal(it) }}
|
||
<span class="doc-name">{{ it.name }}</span>
|
||
<span class="doc-spacer"></span>
|
||
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
|
||
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||
{{ blurtoggle(name_url, it, 'doc-act') }}
|
||
{{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }}
|
||
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
|
||
</summary>
|
||
{% if it.rendered_html %}
|
||
<article class="markdown-body doc-body">{{ it.rendered|safe }}</article>
|
||
{% else %}
|
||
<pre class="textview doc-body">{{ it.rendered }}</pre>
|
||
{% endif %}
|
||
{# The doc branch had `markcontrols` and not `marknotes`, so the
|
||
operator could point at a report and not write down why — on the
|
||
one item kind whose whole content is prose. Exactly the
|
||
"patched two of three" failure the blurtoggle macro above was
|
||
written to prevent, recurring on the macro written to prevent it. #}
|
||
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||
</details>
|
||
</figure>
|
||
{% else %}
|
||
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
|
||
{{ ordinal(it) }}
|
||
{% if it.blurred %}
|
||
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
||
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
|
||
safe direction to fail in. #}
|
||
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
|
||
{% endif %}
|
||
{% if it.kind == 'image' %}
|
||
<a href="view?f={{ it.url }}"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
|
||
{% elif it.kind == 'video' %}
|
||
{# preload="none": a booth of a dozen webms was fetching them
|
||
all at page load ("metadata" still pulls real ranges per
|
||
file); nothing loads until the viewer hits play #}
|
||
<video controls preload="none" src="{{ it.url }}"></video>
|
||
{% elif it.kind == 'audio' %}
|
||
<audio controls preload="none" src="{{ it.url }}"></audio>
|
||
{% elif it.doc %}
|
||
{# a doc too large to inline (over DOC_MAX_BYTES) still links out #}
|
||
<a class="dl doc" href="view?f={{ it.url }}" title="view {{ it.name }}">📄 {{ it.name }}</a>
|
||
{% else %}
|
||
<a class="dl" href="{{ it.url }}" download>⬇ {{ it.name }}</a>
|
||
{% endif %}
|
||
{% if it.kind == 'other' %}
|
||
{# Always render the caption row now: it carries the blur toggle, and
|
||
"no caption" is not a reason to deny an item its controls. #}
|
||
<figcaption>
|
||
{% if it.caption %}<span class="cap-text">{{ it.caption }}</span>{% endif %}
|
||
{{ blurtoggle(name_url, it) }}
|
||
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
|
||
</figcaption>
|
||
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||
{% else %}
|
||
<figcaption>
|
||
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||
<span class="cap-text">{{ it.caption or it.name }}</span>
|
||
{{ blurtoggle(name_url, it) }}
|
||
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
|
||
</figcaption>
|
||
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||
{% endif %}
|
||
</figure>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
</div>{# .lb-set #}
|
||
</div>{# .lightbox #}
|
||
{% endif %}
|
||
|
||
{% if items %}
|
||
<script id="gridkeys">
|
||
/* GRID KEYBOARD — U7. Additive by construction: every action it reaches is a
|
||
control that already exists on the tile and already works with a mouse, so
|
||
the page is complete without this file. It is bound ONLY when there is a
|
||
grid ({% raw %}{% if items %}{% endraw %} above): binding it on the standing
|
||
link board would swallow `f` and flag nothing.
|
||
|
||
Focus moves in RENDER ORDER, which is the item order filtered by the current
|
||
filter and never re-sorted — so `→` walks the grid in the same sequence the
|
||
operator reads it, and the same sequence the zoom ring uses. */
|
||
(function () {
|
||
var grid = document.getElementById('grid');
|
||
if (!grid) return;
|
||
var tiles = function () { return [].slice.call(grid.querySelectorAll('figure.item')); };
|
||
var at = -1;
|
||
function focus(i) {
|
||
var t = tiles();
|
||
if (!t.length) return;
|
||
at = Math.max(0, Math.min(i, t.length - 1));
|
||
t.forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
|
||
t[at].scrollIntoView({ block: 'nearest' });
|
||
}
|
||
function current() { var t = tiles(); return at >= 0 && at < t.length ? t[at] : null; }
|
||
/* WHERE THE CURSOR STARTS WHEN THERE ISN'T ONE. Starting at tile 0
|
||
unconditionally meant the first arrow key after ANY scroll yanked the
|
||
viewport back to the top — and a group jump is a scroll, so `→` right
|
||
after a jump silently undid it. Found by the heid bug-hunt panel (gróa),
|
||
2026-09-22; the general scroll-then-arrow case is the same defect.
|
||
|
||
The first tile whose bottom edge clears the sticky rail is the one the
|
||
reader is looking at, so that is where the cursor picks up. */
|
||
function fromViewport() {
|
||
/* ⚠ `.rail` IS A CROSS-FILE CONTRACT, read by two scripts in two files
|
||
owned by two different agents: this one, and the --rail-h measuring
|
||
script in base.html that publishes the rail's height for
|
||
`scroll-margin-top` (the rail wraps, so no CSS number can know it).
|
||
RENAMING IT BREAKS BOTH, and neither breaks loudly — this one falls back
|
||
to treating the viewport top as the boundary and starts the cursor one
|
||
tile too high; that one falls back to a fixed guess. base.html carries
|
||
the mirror of this note above the `.rail` rule. Agreed with design-dev
|
||
2026-09-23 during the SVOS retheme, which is the change that made the
|
||
selector load-bearing in two places instead of one. */
|
||
var t = tiles(), rail = document.querySelector('.rail');
|
||
var top = rail ? rail.getBoundingClientRect().bottom : 0;
|
||
for (var i = 0; i < t.length; i++) {
|
||
if (t[i].getBoundingClientRect().bottom > top) return i;
|
||
}
|
||
return 0;
|
||
}
|
||
function click(sel) {
|
||
var el = current(); if (!el) return;
|
||
var b = el.querySelector(sel); if (b) b.click();
|
||
}
|
||
document.addEventListener('keydown', function (e) {
|
||
/* Never steal a key the operator is typing into a note or a URL bar. */
|
||
var tag = (e.target.tagName || '').toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
switch (e.key) {
|
||
case 'ArrowRight': focus(at < 0 ? fromViewport() : at + 1); e.preventDefault(); break;
|
||
case 'ArrowLeft': focus(at < 0 ? fromViewport() : at - 1); e.preventDefault(); break;
|
||
/* `.flagbtn` never existed in this repo, so this fell through to the
|
||
HIDDEN target input — and clicking a hidden input does not submit its
|
||
form. `f` has never worked, while still swallowing the keystroke.
|
||
Found by the heid bug-hunt panel (hulda), 2026-09-22. */
|
||
case 'f': click('.flagtoggle button'); e.preventDefault(); break;
|
||
case 'n': var el = current();
|
||
/* The add-note field lives in a closed <details> (270 tiles
|
||
must not each carry an open textarea); a closed one cannot
|
||
take focus, so open it first. */
|
||
var d = el && el.querySelector('details.item-addnote');
|
||
if (d) d.open = true;
|
||
if (el) { var f = el.querySelector('input[type=text], textarea');
|
||
if (f) { f.focus(); e.preventDefault(); } }
|
||
break;
|
||
case 'Enter': click('a[href^="view"]'); break;
|
||
case 'Escape':
|
||
tiles().forEach(function (x) { x.classList.remove('is-cursor'); });
|
||
at = -1; break;
|
||
}
|
||
});
|
||
/* R2 C3: an in-place save swaps the tiles for fresh server-rendered ones,
|
||
and the cursor is client state the server cannot render. Put it back on
|
||
the same position — the order did not change, only the judgment. */
|
||
document.addEventListener('booth:swapped', function () {
|
||
if (at < 0) return;
|
||
tiles().forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
|
||
});
|
||
})();
|
||
</script>
|
||
{% endif %}
|
||
<script>
|
||
/* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain
|
||
HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context
|
||
only) — so fall back to a hidden-textarea execCommand('copy'). */
|
||
(function () {
|
||
function copyText(t) {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
return navigator.clipboard.writeText(t);
|
||
}
|
||
var ta = document.createElement('textarea');
|
||
ta.value = t;
|
||
ta.setAttribute('readonly', '');
|
||
ta.style.position = 'fixed';
|
||
ta.style.top = '-1000px';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try { document.execCommand('copy'); } catch (e) {}
|
||
document.body.removeChild(ta);
|
||
return Promise.resolve();
|
||
}
|
||
document.querySelectorAll('.copy-btn').forEach(function (btn) {
|
||
var label = btn.textContent;
|
||
btn.addEventListener('click', function () {
|
||
copyText(btn.getAttribute('data-copy')).then(function () {
|
||
btn.classList.add('copied');
|
||
btn.textContent = '✓ copied';
|
||
setTimeout(function () { btn.classList.remove('copied'); btn.textContent = label; }, 1300);
|
||
});
|
||
});
|
||
});
|
||
})();
|
||
|
||
/* TILE CONTROLS, bound per node and RE-BOUND after an in-place swap (R2 C3):
|
||
the swap puts fresh server-rendered tiles in the page, and a handler bound
|
||
to the node it replaced goes with that node. `__bound` keeps a node from
|
||
being bound twice.
|
||
|
||
Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
|
||
inside <summary>, so without this its click would just toggle the <details>
|
||
open/closed — stopPropagation + preventDefault make ✕ mean "close", not
|
||
"collapse". Collapse stays available via the rest of the summary bar. With
|
||
JS off the button is inert and collapse via <details> still works.
|
||
|
||
Blur reveal. WARNING: this handler previously sat after the content block's
|
||
closing tag, which in a child template Jinja DISCARDS — the button rendered
|
||
and did nothing, and two commits plus a README claimed click-to-reveal
|
||
worked. Anything that must reach the page belongs inside the content
|
||
block. Per-viewer and never persisted: a reload re-hides. */
|
||
function bindTiles() {
|
||
function once(el) { if (el.__bound) return false; el.__bound = true; return true; }
|
||
/* A form inside <summary> would otherwise collapse the doc on submit. */
|
||
document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) {
|
||
if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); });
|
||
});
|
||
document.querySelectorAll('.doc-close').forEach(function (btn) {
|
||
if (!once(btn)) return;
|
||
btn.addEventListener('click', function (ev) {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
var item = btn.closest('.item-doc');
|
||
if (item) item.classList.add('is-closed');
|
||
});
|
||
});
|
||
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
|
||
var fig = btn.closest('.item');
|
||
/* a swap carries `revealed` across (base.html); the label follows it */
|
||
btn.textContent = fig.classList.contains('revealed') ? '🙈 hide' : '👁 reveal';
|
||
if (!once(btn)) return;
|
||
btn.addEventListener('click', function (ev) {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
var on = fig.classList.toggle('revealed');
|
||
btn.textContent = on ? '🙈 hide' : '👁 reveal';
|
||
});
|
||
});
|
||
}
|
||
bindTiles();
|
||
document.addEventListener('booth:swapped', bindTiles);
|
||
</script>
|
||
{% endblock %}
|