Files
booth/booth/templates/booth.html
T
vh 50f88a3e5e feat(r2): C5 the lightbox, and the in-place client
- 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.
2026-09-23 08:57:36 -07:00

586 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 &lt;url&gt; &lt;name&gt;</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">&times;</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 %}