Files
booth/booth/templates/base.html
T
vh e8e49ceb14 fix(theme): a group jump lands its tile below the sticky rail, not under it
Heid bug-hunt finding (Gróa, relayed by booth-dev). The rail is sticky
and nothing set a scroll margin, so a fragment jump left the target tile,
and the :target reticle that marks it, hidden under the rail.

The rail wraps, so no CSS value can know its height. A small additive
script publishes the measured height as --rail-h, and a ResizeObserver
keeps it current across widths. .item's scroll-margin-top adds 12px to
that. With JS off, a 120px fallback applies.

Also styles the new empty-filter row (397ea89): the filter name in
heading ink, and a gap before the way back.

Measured, 76 group jumps across 2 booths x 4 widths (rail 48-205px):
- JS on: 0 under the rail, minimum clearance 11px.
- JS off: 0 at desktop widths. 19 at 390px, where a wrapped rail is
  143-205px tall and taller than the fallback.
Positive control: the pre-retheme skin fails 74/76.
Merged onto main 1826d19: 660 passed, mutation_check 20/20.
2026-09-23 08:12:54 -07:00

661 lines
46 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.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}The Booth{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2315191d'/%3E%3Cpath d='M7 12V7h5M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23b2cd12'/%3E%3C/svg%3E">
{# The two SVOS voices. display=swap and the system stacks in --font-sans /
--font-mono are the failure mode booth-dev named: a dead or slow link must
degrade to instantly-readable text, never to invisible text. The request
carries the origin only (strict-origin-when-cross-origin), so booth names do
not reach Google. #}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400..700&family=JetBrains+Mono:wght@400..700&display=swap">
<style>
{% include "_svos_tokens.css" %}
/* ============================================================
The Booth — SVOS layer.
Every colour below is a SEMANTIC token from _svos_tokens.css, so dark,
light and both high-contrast variants follow the OS with no second copy.
--text-faint is audited by SVOS as a PLACEHOLDER pair only, so it is kept
to placeholders and decorative glyphs; anything a person reads is
--text-muted or stronger.
The three SVOS devices, each with one job here and nowhere else:
reticle = selection the grid cursor, the jumped-to tile, the
picked option in an ask
hazard = irreversible Wipe now, and bulk-delete once armed
glow = live power the service dot, a live bench
Colour carries one meaning each:
green = armed / primary / focus / selection (and nothing else)
amber = needs you open asks, partial answers, held booths
sage = judged flags, answered asks
intel = information links, notes, the pickup badge
orange = destructive and unreadable state somebody must fix
ORDER IS LOAD-BEARING (CLAUDE.md invariant 6): nothing in this sheet
reorders a collection. No `order`, no column/masonry flow, no reversed
flex. Every wrapped row reads left-to-right in document order.
============================================================ */
*{box-sizing:border-box}
html,body{margin:0}
body{background:var(--surface-base);color:var(--text-body);font-family:var(--font-sans);
font-size:var(--size-body);line-height:var(--leading-body);-webkit-font-smoothing:antialiased;
min-height:100vh;display:flex;flex-direction:column}
::selection{background:var(--selection-bg);color:var(--selection-fg)}
:focus-visible{outline:2px solid var(--border-focus);outline-offset:2px}
a{color:var(--text-link);text-decoration:none}
a:hover{color:var(--text-link-hover);text-decoration:underline}
code{font-family:var(--font-mono);font-size:.9em;background:var(--surface-sunken);color:var(--text-body);
padding:1px 6px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle)}
/* ---- base controls ----------------------------------------------------
A bare <button>, <textarea> or text <input> in the chrome is a quiet SVOS
control. :where() holds specificity at zero, so every classed rule below
wins without a fight and nothing here can override a deliberate one. */
:where(main) :where(button){font:inherit;font-size:var(--size-sm);font-weight:500;line-height:1.2;
padding:6px 11px;border-radius:var(--radius-md);border:1px solid var(--border-strong);
background:var(--surface-raised);color:var(--text-heading);cursor:pointer;
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)}
:where(main) :where(button):hover{background:var(--surface-overlay)}
:where(main) :where(textarea,input[type=text],input[type=url]){font:inherit;font-size:var(--size-body);
color:var(--text-body);background:var(--surface-input);border:1px solid var(--border-strong);
border-radius:var(--radius-lg);padding:8px 11px}
:where(main) :where(textarea,input[type=text],input[type=url])::placeholder{color:var(--text-faint)}
:where(main) :where(textarea,input[type=text],input[type=url]):focus{outline:2px solid var(--border-focus);outline-offset:1px}
:where(main) :where(textarea){resize:vertical}
input[type=checkbox],input[type=radio]{accent-color:var(--accent)}
/* the primary — SVOS primaries arm things, so they are the only green fill */
.drop.has+.up-go,.mark-submit{cursor:pointer;font-family:var(--font-sans);font-weight:600;font-size:var(--size-sm);
line-height:1;padding:11px 16px;border-radius:var(--radius-lg);border:1px solid var(--accent);
background:var(--accent);color:var(--accent-contrast);
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
.drop.has+.up-go:hover,.mark-submit:hover{background:var(--accent-hover);border-color:var(--accent-hover)}
.drop.has+.up-go:active,.mark-submit:active{background:var(--accent-active)}
/* ---- the letterhead --------------------------------------------------- */
.topbar{display:flex;align-items:center;gap:var(--space-4);flex-wrap:wrap;
padding:14px 24px;border-bottom:1px solid var(--border-subtle);background:var(--surface-base)}
.brand{display:inline-flex;align-items:center;gap:10px;color:var(--text-heading)}
.brand:hover{text-decoration:none;color:var(--text-heading)}
/* Device 3. The service answering is the one thing on this page that is
live power, so the dot glows — statically. SVOS: nothing loops. */
.brand .dot{width:8px;height:8px;border-radius:var(--radius-pill);background:var(--accent);
box-shadow:var(--glow-armed)}
.brand .name{font-weight:650;font-size:var(--size-h2);letter-spacing:var(--tracking-h)}
.tagline{font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500;
letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)}
main{flex:1;width:100%;max-width:1240px;margin:0 auto;padding:28px 24px 48px}
.foot{border-top:1px solid var(--border-subtle);color:var(--text-muted);
font-size:var(--size-micro);font-family:var(--font-mono);letter-spacing:.04em;padding:16px 24px;text-align:center}
.foot code{font-size:1em}
.empty{border:1.5px dashed var(--border-strong);border-radius:var(--radius-xl);
padding:48px 24px;text-align:center;color:var(--text-muted);background:var(--surface-sunken);
font-family:var(--font-mono);font-size:var(--size-sm)}
.empty b{color:var(--text-heading);font-weight:600}
.empty a{margin-left:6px}
.rail+.empty{margin-bottom:16px}
/* ---- upload / pickup -------------------------------------------------- */
.uploader{display:flex;gap:var(--space-3);align-items:stretch;margin-bottom:28px;flex-wrap:wrap}
.drop{flex:1 1 300px;position:relative;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:2px;text-align:center;cursor:pointer;padding:18px 16px;
border:1.5px dashed var(--border-strong);border-radius:var(--radius-xl);background:var(--surface-sunken);
transition:border-color var(--dur-1) var(--ease-out),background var(--dur-1) var(--ease-out)}
.drop:hover{border-color:var(--text-faint)}
.drop:focus-within{outline:2px solid var(--border-focus);outline-offset:2px}
.drop.over{border-color:var(--accent);background:var(--accent-soft)}
.drop.has{border-style:solid;border-color:var(--accent)}
.drop input[type=file]{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}
.drop-icon{font-size:18px;color:var(--text-muted)}
.drop-main{font-weight:600;color:var(--text-heading);font-size:var(--size-h3)}
.drop-sub{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
/* Quiet until files are chosen: the upload is secondary on the index, and a
green slab there would be the loudest thing on the page. Once .drop.has is
set, the upload is ARMED and the rule above turns it primary. With JS off
it stays quiet and still submits. */
.up-go{flex:0 0 auto;align-self:stretch;padding:0 20px;cursor:pointer;font-family:var(--font-sans);
font-weight:600;font-size:var(--size-sm);border-radius:var(--radius-xl);border:1px solid var(--border-strong);
background:var(--surface-raised);color:var(--text-heading);
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)}
.up-go:hover{background:var(--surface-overlay)}
.drop.has+.up-go{color:var(--accent-contrast);padding:0 20px;border-radius:var(--radius-xl)}
.pickup-note{margin:-8px 0 24px;padding:10px 14px;border:1px solid var(--border-subtle);
border-left:3px solid var(--intel);border-radius:var(--radius-md);background:var(--intel-soft);
font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-body)}
.copy-btn{cursor:pointer;font-family:var(--font-mono);font-size:var(--size-micro);
padding:3px 8px;margin:0 4px;border:1px solid var(--border-strong);border-radius:var(--radius-sm);
background:transparent;color:var(--text-muted);vertical-align:middle;
transition:border-color var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)}
.copy-btn:hover{border-color:var(--text-faint);color:var(--text-heading)}
.copy-btn.copied{border-color:var(--success);color:var(--success-text)}
.dl-link{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-link);margin-right:6px}
.dl-link:hover{color:var(--text-link-hover)}
.cap-text{color:var(--text-body)}
/* ---- badges and stamps -----------------------------------------------
A badge is a fact about a booth. On a thumbnail it sits on an opaque
scrim, because a tinted-glass badge is illegible over half the images a
booth can hold. */
.badge{display:inline-flex;align-items:center;gap:5px;font-family:var(--font-mono);font-size:var(--size-micro);
font-weight:500;letter-spacing:.08em;text-transform:uppercase;line-height:1.3;
padding:2px 7px;border-radius:var(--radius-sm);vertical-align:middle;
color:var(--intel-text);background:var(--intel-soft);border:1px solid color-mix(in oklab,var(--intel) 40%,transparent)}
.badge-kept{color:var(--text-muted);background:var(--surface-sunken);border-color:var(--border-strong)}
.badge-mark{color:var(--warning-text);background:var(--warning-soft);border-color:color-mix(in oklab,var(--warning) 45%,transparent)}
.thumb .badge{position:absolute;top:10px;left:10px;background:color-mix(in oklab,var(--surface-base) 88%,transparent);
box-shadow:var(--shadow-sm)}
.thumb .badge+.badge-mark{top:36px}
/* ---- index: lanes and cards ------------------------------------------ */
.lane-head{margin:28px 0 12px;font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500;
letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted);
display:flex;align-items:center;gap:10px}
.lane-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)}
.lane-note{font-weight:400;letter-spacing:.04em;color:var(--text-muted);text-transform:none}
.lane-note code{font-size:1em;color:var(--text-muted)}
.uploader+.lane-head{margin-top:8px}
.kept-grid{margin-bottom:4px}
.grid{display:grid;gap:var(--space-4);grid-template-columns:repeat(auto-fill,minmax(248px,1fr))}
.card{position:relative;background:var(--surface-card);border:1px solid var(--border-default);
border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-sm);
transition:border-color var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out)}
.card:hover{border-color:var(--border-strong);box-shadow:var(--shadow-md)}
.card .thumb{position:relative;display:block;aspect-ratio:16/10;background:var(--surface-sunken);
overflow:hidden;border-bottom:1px solid var(--border-subtle)}
.card .thumb img{width:100%;height:100%;object-fit:cover;display:block}
.card .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;
color:var(--text-faint);font-family:var(--font-mono);font-size:var(--size-caption);
letter-spacing:var(--tracking-caps);text-transform:uppercase}
.card .meta{padding:12px 14px 14px}
.card .name{display:block;font-weight:600;font-size:var(--size-h3);color:var(--text-heading);word-break:break-word}
.card .name:hover{text-decoration:none;color:var(--text-link)}
.card .sub{color:var(--text-muted);font-size:var(--size-caption);font-family:var(--font-mono);margin-top:4px}
/* THE ANNOUNCEMENT — who posted this booth and why (U5). Same size and
rhythm as .sub above it: a second line of card metadata, not a heading.
The handle carries the brightest ink, so a scan down the index reads as a
column of posters. */
.prov{margin-top:6px;font-size:var(--size-caption);font-family:var(--font-mono);
color:var(--text-muted);line-height:1.5;overflow-wrap:anywhere}
.prov-who{color:var(--text-heading);font-weight:500}
.prov-why{color:var(--text-muted)}
/* Quiet on purpose — visible if you look, never a badge shouting 26 times.
`unreadable` gets the warning ink because somebody has to fix it. */
.prov-none{color:var(--text-muted);font-style:italic}
.prov-broken{color:var(--warning-text);font-style:italic;cursor:help}
/* card-shoulder controls: an opaque scrim and an always-light glyph, so
they read over any thumbnail in both themes */
.wipe{position:absolute;top:10px;right:10px;margin:0}
.wipe button,.keepit button,.wipe-kept button,.release button{cursor:pointer;font:inherit;
font-size:var(--size-sm);line-height:1;height:28px;min-width:28px;padding:0 8px;
border-radius:var(--radius-md);border:1px solid rgb(255 255 255 / .16);
background:oklch(0.17 0.01 250 / .86);color:oklch(0.91 0.008 216);
-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
.wipe button{font-size:16px}
.wipe button:hover,.wipe-kept button:hover{background:var(--danger);border-color:var(--danger);color:var(--danger-contrast)}
.keepit{position:absolute;top:10px;left:10px;margin:0;opacity:0;transition:opacity var(--dur-1)}
.card:hover .keepit,.keepit:focus-within{opacity:1}
.keepit button:hover,.release button:hover{background:oklch(0.31 0.012 244 / .95);border-color:rgb(255 255 255 / .32)}
/* One positioned row holds BOTH kept-card controls — they used to pin
themselves to the same corner and the later sibling won. */
.kept-actions{position:absolute;top:10px;right:10px;display:flex;gap:6px;
align-items:center;opacity:0;transition:opacity var(--dur-1)}
.card-kept:hover .kept-actions,.kept-actions:focus-within{opacity:1}
.kept-actions form{position:static;opacity:1;margin:0}
.release{opacity:0;transition:opacity var(--dur-1)}
.card-kept:hover .release,.release:focus-within{opacity:1}
.release button{font-size:var(--size-caption);font-family:var(--font-mono)}
/* ---- lifetime ---------------------------------------------------------
U4: a booth that stopped counting down says so where the countdown was. */
.held{color:var(--warning-text)}
.held-broken{color:var(--danger-text);text-decoration:underline dotted}
/* ---- the booth page header ------------------------------------------- */
.boothhead{display:flex;align-items:center;gap:var(--space-3) var(--space-4);flex-wrap:wrap;
padding-bottom:18px;margin-bottom:22px;border-bottom:1px solid var(--border-subtle)}
.boothhead .back{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
.boothhead .back:hover{color:var(--text-heading)}
.boothhead h1{margin:0;font-weight:650;font-size:var(--size-h1);line-height:var(--leading-tight);
letter-spacing:var(--tracking-h);word-break:break-word;flex:1 1 auto;color:var(--text-heading)}
.boothhead .sub{color:var(--text-muted);font-size:var(--size-caption);font-family:var(--font-mono)}
/* Its own row under the title — a `why` can run to WHY_MAX and would
otherwise shove the zip link around. */
.boothhead .prov{flex:0 0 100%;margin-top:-4px}
/* The directory name beside a manifest title: quieter than the title, never
absent — it is what the URL says and what "the third one" refers to. */
.h1-slug{font-family:var(--font-mono);font-size:.54em;font-weight:400;
letter-spacing:0;color:var(--text-muted);margin-left:8px;white-space:nowrap}
.keep-lg{display:inline-block;margin:0}
.keep-lg button{font-size:var(--size-sm)}
.wipe-lg{position:static}
/* Device 2. Wiping a booth is the one act on this page that nothing brings
back, so it alone wears the stripe. The fill waits for the hover. */
.wipe-lg button{height:auto;min-width:0;padding:10px 14px 8px;border-radius:var(--radius-md);
font-family:var(--font-sans);font-size:var(--size-sm);font-weight:600;letter-spacing:.02em;
background:var(--hazard-stripe) top / 100% 3px no-repeat,transparent;
border:1px solid var(--danger);color:var(--danger-text);-webkit-backdrop-filter:none;backdrop-filter:none}
.wipe-lg button:hover{background:var(--hazard-stripe) top / 100% 3px no-repeat,var(--danger);
color:var(--danger-contrast);border-color:var(--danger)}
/* ---- marks: the Bureau ------------------------------------------------
Judgment is paperwork. A pick is a form, a note is a memo, a flag is a
stamp. The state word is a stamp in the header; only a mark that still
needs the operator carries a coloured top edge, so an open ask is the one
thing in the panel that asks to be looked at. */
.marks{display:flex;flex-direction:column;gap:var(--space-3);margin:0 0 24px}
.mark{background:var(--surface-card);border:1px solid var(--border-default);
border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-sm)}
.mark-pick:not(.is-answered):not(.is-broken){border-top:2px solid var(--warning)}
.mark-head{display:flex;align-items:center;flex-wrap:wrap;gap:6px 10px;padding:9px 14px;
border-bottom:1px solid var(--border-subtle);background:var(--surface-raised);
font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
.mark-head code{font-size:1em}
.mark-state{font-family:var(--font-mono);font-size:10.5px;font-weight:600;letter-spacing:var(--tracking-caps);
text-transform:uppercase;line-height:1.3;padding:2px 7px;border:1.5px solid currentColor;
border-radius:var(--radius-sm);color:var(--warning-text)}
.mark.is-answered .mark-state{color:var(--success-text);transform:rotate(-2deg)}
.mark.is-partial .mark-state{color:var(--warning-text)}
.mark.is-broken .mark-state{color:var(--danger-text)}
.mark-state-note{color:var(--intel-text)}
.mark-state-flag{color:var(--success-text)}
.mark-part{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--warning-text);font-weight:600}
.mark-when{white-space:nowrap;color:var(--text-muted)}
.mark-target a{font-family:var(--font-mono)}
.mark-x{background:none;border:0;color:var(--text-faint);cursor:pointer;font-size:16px;line-height:1;
padding:2px 6px;border-radius:var(--radius-sm)}
.mark-x:hover{color:var(--danger-text);background:var(--danger-soft)}
.mark-undo{margin:0}
.board-spacer{flex:1 1 auto}
.mark-title{margin:14px 16px -4px;font-family:var(--font-mono);font-size:var(--size-micro);
letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)}
.mark-prompt{margin:14px 16px 8px;font-size:var(--size-h3);font-weight:600;color:var(--text-heading);white-space:pre-wrap}
.mark-error{margin:14px 16px;color:var(--danger-text);font-size:var(--size-sm)}
.mark-answer{margin:4px 16px 12px;padding:10px 14px;border:1px solid color-mix(in oklab,var(--success) 35%,transparent);
border-radius:var(--radius-md);background:var(--success-soft)}
.mark-answer-choice{font-weight:600;color:var(--text-heading)}
.mark-answer-choice::before{content:"✓ ";color:var(--success-text)}
.mark-answer-choice.is-skipped{opacity:.6;font-style:italic;font-weight:500}
.mark-answer-choice.is-skipped::before{content:"— ";color:var(--text-faint)}
.mark-answer-notes{margin:6px 0 0;white-space:pre-wrap;font-family:var(--font-sans);font-size:var(--size-sm);color:var(--text-body)}
.mark-answer-file{display:block;margin-top:6px;font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted)}
.mark-answer-file a{color:var(--text-muted)}
.mark-answer-q{padding:6px 0;border-bottom:1px dashed var(--border-default)}
.mark-answer-q:last-of-type{border-bottom:0}
.mark-answer-qprompt{display:block;font-size:var(--size-caption);color:var(--text-muted)}
.mark-formwrap{margin:0 16px 14px}
.mark-change{cursor:pointer;font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:var(--tracking-caps);
text-transform:uppercase;color:var(--text-muted);list-style:none;user-select:none;padding:2px 0}
.mark-change::-webkit-details-marker{display:none}
.mark-formwrap[open]>.mark-change{margin-bottom:8px}
.mark-formwrap:not([open])>.mark-change{color:var(--text-link)}
.mark-q{border:0;margin:0 0 16px;padding:0;min-width:0}
.mark-q:last-of-type{margin-bottom:0}
.mark-q-prompt{padding:0;margin:0 0 8px;font-size:var(--size-body);font-weight:600;color:var(--text-heading)}
.mark-options{display:flex;flex-direction:column;gap:6px}
.mark-opt{position:relative;display:flex;align-items:flex-start;gap:10px;padding:10px 12px;cursor:pointer;
border:1px solid var(--border-default);border-radius:var(--radius-lg);background:var(--surface-base);
transition:border-color var(--dur-1) var(--ease-out),background var(--dur-1) var(--ease-out)}
.mark-opt:hover{border-color:var(--border-strong)}
.mark-opt:has(input:checked){border-color:color-mix(in oklab,var(--accent) 55%,transparent);background:var(--accent-soft)}
.mark-opt input{margin:3px 0 0;flex:0 0 auto}
.mark-opt-main{display:flex;flex-direction:column;gap:2px;min-width:0}
.mark-opt-label{font-size:var(--size-body);font-weight:500;color:var(--text-heading)}
.mark-opt-detail{font-size:var(--size-caption);color:var(--text-muted);white-space:pre-wrap}
.mark-notes{display:block;width:100%;margin:10px 0 0}
.mark-qnotes{margin-top:6px;font-size:var(--size-sm)}
.mark-actions{display:flex;justify-content:flex-end;margin-top:12px}
/* a note is a memo: the operator's words, set to be read */
.mark-text,.vnote pre,pre.vnote,.item-note pre{margin:0;padding:8px 14px;white-space:pre-wrap;
font-family:var(--font-sans);font-size:var(--size-sm);line-height:var(--leading-body);color:var(--text-body);
border-left:3px solid var(--border-strong);background:none}
.mark-text{margin:12px 16px}
/* The flag list is the way back to the flagged tiles. Wrapped chips, not a
column: a 37-flag booth must not push its own grid below the fold. The
chips wrap left-to-right in list order, so the sequence is untouched. */
.mark-flaglist{display:flex;flex-wrap:wrap;gap:6px;margin:0;padding:12px 14px;list-style:none}
.mark-flaglist a{display:inline-block;font-family:var(--font-mono);font-size:var(--size-caption);
padding:3px 8px;border-radius:var(--radius-sm);border:1px solid color-mix(in oklab,var(--success) 40%,transparent);
background:var(--success-soft);color:var(--success-text)}
.mark-flaglist a:hover{text-decoration:none;border-color:var(--success)}
.mark-add,.vaddnote,.item-addnote form{display:flex;gap:8px;align-items:flex-start;margin:0}
.mark-add textarea,.vaddnote textarea,.item-addnote textarea{flex:1;min-width:0}
.mark-add{margin-top:2px}
/* ---- the gallery ------------------------------------------------------ */
/* THE RAIL. Sticky, opaque, and counted in mono because the counts are
records. The active filter is a place you are, not an armed thing, so it
is lifted rather than coloured. */
/* A fragment jump or a cursor move must land its tile BELOW the stuck rail,
not under it (heid bug-hunt, 2026-09-23). The rail wraps, so its height is
not knowable in CSS: --rail-h is measured by the script at the foot of
this template, and the 120px fallback clears a three-line desktop rail with JS off. */
.rail{position:sticky;top:0;z-index:5;display:flex;gap:6px 8px;align-items:center;flex-wrap:wrap;
padding:10px 0;margin:0 0 16px;background:var(--surface-base);
border-bottom:1px solid var(--border-subtle);font-family:var(--font-mono);font-size:var(--size-caption)}
.rail-total{font-family:var(--font-sans);font-weight:600;font-size:var(--size-sm);color:var(--text-heading);margin-right:4px}
.rail-f,.rail-g{padding:3px 9px;border-radius:var(--radius-md);text-decoration:none;
color:var(--text-muted);border:1px solid transparent;
transition:color var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out),background var(--dur-1) var(--ease-out)}
.rail-f b,.rail-g b{font-weight:600;color:var(--text-body);margin-left:2px}
.rail-f:hover,.rail-g:hover{color:var(--text-heading);border-color:var(--border-default);text-decoration:none}
.rail-f.on{color:var(--text-heading);background:var(--surface-raised);border-color:var(--border-strong)}
.rail-f.on b{color:var(--text-heading)}
/* The group row wraps rather than scrolls: 16 groups is the live maximum
and a horizontal scroller hides half of them behind a gesture. */
.rail-groups{display:flex;gap:4px;flex-wrap:wrap;align-items:center;
padding-left:10px;margin-left:4px;border-left:1px solid var(--border-default)}
/* align-items:start — each tile ends where its content does, instead of
every tile in a row stretching to the longest caption. Row-major order is
untouched: this sizes boxes, it moves none. */
.gallery{display:grid;gap:var(--space-5);grid-template-columns:repeat(auto-fill,minmax(320px,1fr));align-items:start}
.item{position:relative;margin:0;background:var(--surface-card);border:1px solid var(--border-default);
border-radius:var(--radius-xl);overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-sm);
scroll-margin-top:calc(var(--rail-h, 120px) + 12px)}
.item img,.item video{width:100%;height:auto;display:block;background:var(--surface-sunken)}
.item audio{width:100%;margin:20px 14px 6px;max-width:calc(100% - 28px)}
.item .dl{padding:22px 14px;font-family:var(--font-mono);font-size:var(--size-sm);word-break:break-all}
/* The caption is the poster's prose (up to CAPTION_MAX), so it is SET TO BE
READ, in the speaking voice, at the tile's full measure; the controls are
records, stay mono, and wrap onto their own row at the right edge — the
same place on every tile, however long the caption runs. */
.item figcaption{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:8px;align-items:baseline;
padding:10px 12px;color:var(--text-muted);font-size:var(--size-caption);font-family:var(--font-mono);
border-top:1px solid var(--border-subtle);word-break:break-word}
.item figcaption .cap-text{grid-column:2 / -1;font-family:var(--font-sans);font-size:var(--size-sm);line-height:1.5}
.item figcaption .blurtoggle{grid-row:2;grid-column:3}
.item figcaption .flagtoggle{grid-row:2;grid-column:4}
.item figcaption .dl-link{margin:0}
.item-audio figcaption,.item-other figcaption{border-top:none}
/* note slots on a tile: the memo, then the quiet "+ note" disclosure */
.item-note{display:flex;align-items:flex-start;gap:4px;padding:0 12px 8px}
.item-note pre{flex:1;min-width:0}
.item-addnote{padding:0 12px 10px}
.item-addnote summary{cursor:pointer;font-family:var(--font-mono);font-size:var(--size-micro);
color:var(--text-muted);padding:2px 0;list-style:none}
.item-addnote summary::-webkit-details-marker{display:none}
.item-addnote summary:hover{color:var(--text-heading)}
.item-addnote[open] summary{margin-bottom:6px}
/* the tile toggles — small, quiet, and legible; a control nobody can find
is a control that is not there */
.flagtoggle,.blurtoggle{display:inline-block;margin:0}
.flagtoggle button,.blurtoggle button{font-family:var(--font-mono);font-size:var(--size-micro);
font-weight:500;line-height:1.3;padding:3px 8px;border-radius:var(--radius-sm);white-space:nowrap;
background:transparent;border:1px solid var(--border-strong);color:var(--text-muted)}
.flagtoggle button:hover{border-color:var(--success);color:var(--success-text);background:var(--success-soft)}
.blurtoggle button:hover{border-color:var(--text-faint);color:var(--text-heading);background:transparent}
/* state read off the form itself (`on=0` means "currently set"), so every
branch — tile, doc bar, other — shows it without a class of its own */
.flagtoggle:has(input[name="on"][value="0"]) button{border-color:var(--success);color:var(--success-text);
background:var(--success-soft);font-weight:600}
.blurtoggle:has(input[name="on"][value="0"]) button{color:var(--text-heading);border-style:dashed}
/* A flag is the operator's stamp. Sage, not green: judged, not armed. The
stamp is drawn by the tile itself, so no markup is added. */
.item.is-flagged{border-color:var(--success);box-shadow:0 0 0 1px var(--success),var(--shadow-sm)}
.item.is-flagged::before{content:"flagged";position:absolute;top:10px;right:10px;z-index:2;pointer-events:none;
font:600 10.5px/1.3 var(--font-mono);letter-spacing:var(--tracking-caps);text-transform:uppercase;
padding:3px 8px;border:1.5px solid currentColor;border-radius:var(--radius-sm);color:var(--success-text);
background:color-mix(in oklab,var(--surface-base) 88%,transparent);transform:rotate(-2deg)}
/* Device 1 — the RETICLE: selection. Four corner brackets drawn by ONE
pseudo-element, so no markup is added. They sit 4px INSIDE the box: an
overflow:hidden tile clips an outside bracket's entire stroke (the SVOS
foot-gun). A hairline shadow keeps them visible over a bright image. */
.item.is-cursor::after,.item:target::after,.mark-opt:has(input:checked)::after{content:"";position:absolute;inset:4px;z-index:3;
pointer-events:none;--rl:16px;--rt:2px;
background:
linear-gradient(var(--accent) 0 0) top left / var(--rl) var(--rt),
linear-gradient(var(--accent) 0 0) top left / var(--rt) var(--rl),
linear-gradient(var(--accent) 0 0) top right / var(--rl) var(--rt),
linear-gradient(var(--accent) 0 0) top right / var(--rt) var(--rl),
linear-gradient(var(--accent) 0 0) bottom left / var(--rl) var(--rt),
linear-gradient(var(--accent) 0 0) bottom left / var(--rt) var(--rl),
linear-gradient(var(--accent) 0 0) bottom right / var(--rl) var(--rt),
linear-gradient(var(--accent) 0 0) bottom right / var(--rt) var(--rl);
background-repeat:no-repeat;filter:drop-shadow(0 0 1px oklch(0.14 0.01 250 / .7))}
.mark-opt:has(input:checked)::after{inset:3px;--rl:10px;--rt:1.5px;filter:none}
.item.is-cursor{border-color:color-mix(in oklab,var(--accent) 60%,transparent)}
/* ⚠ Blur is COSMETIC. The file is still served at its own URL and still in
the zip. This hides an item from a glance, nothing more — and nothing here
may style it as though it were secured: no lock, no shield, no seal. */
.item.blurred img,.item.blurred video,
.item.blurred .doc-body,.item.blurred .textview{filter:blur(22px);transition:filter var(--dur-2)}
.item.blurred.revealed img,.item.blurred.revealed video,
.item.blurred.revealed .doc-body,.item.blurred.revealed .textview{filter:none}
.item.blurred .reveal{position:absolute;top:10px;left:10px;z-index:2;cursor:pointer;
font-family:var(--font-mono);font-size:var(--size-micro);line-height:1;padding:6px 9px;
border-radius:var(--radius-md);border:1px solid rgb(255 255 255 / .16);
background:oklch(0.17 0.01 250 / .86);color:oklch(0.91 0.008 216)}
.item.blurred .reveal:hover{background:oklch(0.31 0.012 244 / .95)}
/* Cover thumbs on the index inherit the blur so the front page cannot undo it. */
.blurred-thumb{filter:blur(16px)}
/* ---- inline docs ------------------------------------------------------
A .md/.txt/.log shows in place, collapsible and closable, spanning the
full grid width so prose has a readable measure. */
.item-doc{grid-column:1 / -1}
.item-doc.is-closed{display:none}
.doc-inline{display:block}
.doc-inline > .doc-bar{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px;
padding:9px 14px;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted);
background:var(--surface-raised);border-bottom:1px solid var(--border-subtle);user-select:none}
.doc-inline > .doc-bar::-webkit-details-marker{display:none}
.doc-chevron{color:var(--text-faint);transition:transform var(--dur-1) var(--ease-out);font-size:10px}
.doc-inline[open] > .doc-bar .doc-chevron{transform:rotate(90deg)}
.doc-name{color:var(--text-heading);word-break:break-all}
.doc-spacer{flex:1}
.doc-act{color:var(--text-muted);text-decoration:none;padding:2px 6px;border-radius:var(--radius-sm);
font-size:13px;line-height:1;background:none;border:0;cursor:pointer;font-family:inherit}
.doc-act:hover{color:var(--text-heading);background:var(--surface-sunken);text-decoration:none}
.doc-close:hover{color:var(--text-heading)}
.doc-bar .blurtoggle,.doc-bar .flagtoggle{padding:0;background:none}
.doc-bar .blurtoggle{margin-left:4px}
.doc-body{margin:0;border:0;border-radius:0;max-height:32rem;overflow:auto;padding:18px 20px}
.doc-body.textview{background:var(--surface-card);border:0;border-radius:0}
/* Shared doc typography — the inline body above AND the full-page doc view
(doc.html) — so both surfaces render identically. */
.textview{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);
font-size:var(--size-mono);line-height:1.6;color:var(--text-body);background:var(--surface-sunken);
border:1px solid var(--border-subtle);border-radius:var(--radius-lg);padding:16px 18px;overflow-x:auto}
.markdown-body{color:var(--text-body);line-height:1.65;font-size:15px;overflow-wrap:break-word}
.markdown-body h1,.markdown-body h2,.markdown-body h3{color:var(--text-heading);line-height:1.25;
letter-spacing:var(--tracking-h);margin:1.6em 0 .5em;font-weight:650}
.markdown-body h1{font-size:1.6em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.08em;font-weight:600}
.markdown-body h1,.markdown-body h2{border-bottom:1px solid var(--border-subtle);padding-bottom:.3em}
.markdown-body :first-child{margin-top:0}
.markdown-body p,.markdown-body ul,.markdown-body ol,.markdown-body blockquote{margin:.7em 0}
.markdown-body strong{color:var(--text-heading)}
.markdown-body a{color:var(--text-link)}
.markdown-body code{font-family:var(--font-mono);font-size:.86em;background:var(--surface-sunken);
padding:.12em .38em;border-radius:var(--radius-sm);border:1px solid var(--border-subtle)}
.markdown-body pre{background:var(--surface-sunken);border:1px solid var(--border-subtle);
border-radius:var(--radius-lg);padding:14px 16px;overflow-x:auto}
.markdown-body pre code{background:none;padding:0;border:0}
.markdown-body blockquote{border-left:3px solid var(--border-strong);padding-left:1em;color:var(--text-muted);margin-left:0}
.markdown-body table{border-collapse:collapse;display:block;overflow-x:auto;font-size:var(--size-sm)}
.markdown-body th{text-align:left;font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:.08em;
text-transform:uppercase;font-weight:500;color:var(--text-muted);border-bottom:1px solid var(--border-strong)}
.markdown-body th,.markdown-body td{border:1px solid var(--border-subtle);padding:7px 11px}
.markdown-body img{max-width:100%}
.markdown-body hr{border:0;border-top:1px solid var(--border-subtle)}
/* ---- the zoom viewer (fixed full-viewport overlay) -------------------- */
.viewer{position:fixed;inset:0;z-index:50;background:var(--surface-sunken);display:flex;flex-direction:column}
.vbar{display:flex;align-items:center;gap:10px;padding:8px 12px;
border-bottom:1px solid var(--border-subtle);background:var(--surface-base)}
.vname{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-body);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50vw}
.vspacer{flex:1}
.vbtn{display:inline-flex;align-items:center;justify-content:center;min-width:32px;height:32px;
padding:0 10px;border:1px solid var(--border-strong);border-radius:var(--radius-md);
color:var(--text-body);background:var(--surface-raised);font-family:var(--font-mono);font-size:var(--size-sm);
cursor:pointer;transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
.vbtn:hover{border-color:var(--text-faint);color:var(--text-heading);background:var(--surface-overlay);text-decoration:none}
.vbtn.is-flagged{color:var(--success-text);border-color:var(--success);background:var(--success-soft);font-weight:600}
.vtoggle{display:inline-flex;border:1px solid var(--border-strong);border-radius:var(--radius-md);overflow:hidden}
.vseg{cursor:pointer;border:0;background:transparent;color:var(--text-muted);font-family:var(--font-mono);
font-size:var(--size-caption);padding:7px 12px;transition:background var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)}
.vseg+.vseg{border-left:1px solid var(--border-strong)}
.vseg:hover{color:var(--text-heading)}
.vseg.on{background:var(--surface-overlay);color:var(--text-heading)}
.vstage{flex:1;min-height:0;background:var(--surface-sunken)}
.vstage.fit{display:flex;align-items:center;justify-content:center;overflow:hidden;padding:16px}
.vstage.fit img{max-width:100%;max-height:100%;width:auto;height:auto;box-shadow:var(--shadow-lg)}
.vstage.one{overflow:auto;text-align:center}
.vstage.one img{max-width:none;max-height:none;margin:auto}
.vmarks{margin:0 auto;padding:10px 12px 12px;width:min(92vw,900px);display:flex;flex-direction:column;gap:8px}
.vflag{display:flex}
.vnote{display:flex;align-items:flex-start;gap:4px}
.vnote pre{flex:1;min-width:0}
pre.vnote{display:block}
/* ---- the standing link board ------------------------------------------
Rows, not a markdown blob. Dense enough that thirty entries stay
scannable; provenance recedes so the description leads, and the × only
surfaces on hover — destructive controls do not compete for attention
with the thing you came to read. */
.board{border:1px solid var(--border-default);border-radius:var(--radius-xl);overflow:hidden;
background:var(--surface-card);margin:10px 0 16px;box-shadow:var(--shadow-sm)}
.board-head{display:flex;align-items:center;gap:10px;padding:10px 14px;
border-bottom:1px solid var(--border-subtle);background:var(--surface-raised)}
.board-selall{display:inline-flex;align-items:center;cursor:pointer;flex:0 0 auto}
.board-title{font-weight:600;font-size:var(--size-sm);color:var(--text-heading)}
.board-note{font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted)}
/* Bulk delete: quiet until something is selected. Once rows are ticked it
is ARMED with the hazard stripe — device 2, earned by the selection. */
.board-del-sel{flex:0 0 auto;cursor:pointer;font-family:var(--font-mono);font-size:var(--size-micro);
padding:6px 10px 5px;border-radius:var(--radius-sm);
background:var(--hazard-stripe) top / 100% 3px no-repeat,transparent;
border:1px solid var(--danger);color:var(--danger-text);transition:background var(--dur-1) var(--ease-out)}
.board-del-sel:hover:not(:disabled){background:var(--hazard-stripe) top / 100% 3px no-repeat,var(--danger);color:var(--danger-contrast)}
.board-del-sel:disabled{background:transparent;opacity:.5;cursor:default;border-color:var(--border-strong);color:var(--text-faint)}
.board-row{display:flex;align-items:center;gap:10px;padding:9px 14px;
border-bottom:1px solid var(--border-subtle);transition:background var(--dur-1)}
.board-row:last-child{border-bottom:0}
.board-row:hover{background:var(--surface-sunken)}
/* Pinned rows are lifted, not coloured; the ★ carries the state. */
.board-row.is-pinned{background:var(--surface-overlay)}
.board-row.is-pinned:hover{background:var(--surface-sunken)}
.board-check,.board-selall input{flex:0 0 auto;width:15px;height:15px;cursor:pointer;margin:0}
.board-pin{flex:0 0 auto;border:0;background:none;cursor:pointer;line-height:1;
font-size:16px;padding:2px 4px;border-radius:var(--radius-sm);color:var(--text-faint);
transition:color var(--dur-1)}
.board-pin:hover,.board-pin.on{color:var(--text-heading)}
.board-main{flex:1 1 auto;min-width:0}
.board-link{font-size:var(--size-body);font-weight:500}
.board-url{font-size:var(--size-micro);color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;font-family:var(--font-mono)}
.board-meta{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end;
gap:1px;font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted);white-space:nowrap}
.board-who{color:var(--text-body);font-weight:500}
.board-copy,.board-rm-btn{opacity:0;transition:opacity var(--dur-1);flex:0 0 auto}
.board-row:hover .board-copy,.board-row:hover .board-rm-btn,
.board-copy:focus,.board-rm-btn:focus{opacity:1}
.board-rm-btn{font:inherit;font-size:16px;line-height:1;padding:3px 7px;
border:0;background:none;cursor:pointer;color:var(--text-muted);border-radius:var(--radius-sm)}
.board-rm-btn:hover{background:var(--danger);color:var(--danger-contrast)}
/* A board row whose booth has been swept. Marked, never auto-removed. */
.board-row.board-dead{opacity:.5}
.board-dead-tag{font-style:italic;color:var(--text-muted)}
@media (max-width:600px){
/* No hover on touch — controls must be permanently visible or unreachable. */
.board-copy,.board-rm-btn{opacity:1}
.board-meta{display:none}
}
/* ---- U6: the bench registry, on the standing board's page only -------- */
.benches{margin:0 0 16px;border:1px solid var(--border-default);border-radius:var(--radius-xl);
overflow:hidden;background:var(--surface-card);box-shadow:var(--shadow-sm)}
.bench-head{display:flex;gap:10px;align-items:baseline;padding:10px 14px;
background:var(--surface-raised);border-bottom:1px solid var(--border-subtle)}
.bench-title{font-weight:600;font-size:var(--size-sm);color:var(--text-heading)}
.bench-note{font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted)}
.bench-empty{padding:12px 14px;color:var(--text-muted);font-size:var(--size-sm)}
/* DAMAGED AND ABSENT MUST NOT RENDER THE SAME. */
.bench-err{padding:12px 14px;color:var(--danger-text);background:var(--danger-soft);font-size:var(--size-sm)}
.bench-row{display:flex;gap:10px;align-items:center;padding:9px 14px;border-top:1px solid var(--border-subtle)}
.bench-head+.bench-row{border-top:0}
.bench-row.is-retired{opacity:.5}
.bench-state{display:inline-flex;align-items:center;gap:6px;flex:0 0 5.6rem;justify-content:flex-start;
font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:.08em;text-transform:uppercase;
color:var(--text-muted)}
/* Device 3: a live bench is a running thing — the one row that glows. */
.bench-row.is-live .bench-state{color:var(--accent-text)}
.bench-row.is-live .bench-state::before{content:"";width:6px;height:6px;border-radius:var(--radius-pill);
background:var(--accent);box-shadow:var(--glow-armed)}
.bench-row.is-promoted .bench-state{color:var(--intel-text)}
.bench-row.is-promoted .bench-state::before{content:"";width:6px;height:6px;border-radius:var(--radius-pill);background:var(--intel)}
.bench-main{flex:1;min-width:0}
.bench-link{font-weight:500}
.bench-url{font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bench-meta{display:flex;flex-direction:column;align-items:flex-end;font-family:var(--font-mono);
font-size:var(--size-micro);color:var(--text-muted)}
.bench-who{color:var(--text-body)}
.bench-acts{display:flex;gap:4px;margin:0}
.bench-to{font-family:var(--font-mono);font-size:var(--size-micro);padding:3px 8px}
.bench-rm{font-size:14px;padding:3px 8px;background:none;border-color:transparent;color:var(--text-muted)}
.bench-rm:hover{background:var(--danger);border-color:var(--danger);color:var(--danger-contrast)}
.bench-add{display:flex;gap:8px;padding:10px 14px;border-top:1px solid var(--border-subtle);background:var(--surface-raised)}
.bench-add input[type=url]{flex:2;min-width:0}
.bench-add input[type=text]{flex:1;min-width:0}
@media (max-width:600px){
main{padding:20px 16px 40px}
.topbar{padding:12px 16px}
.gallery{grid-template-columns:1fr}
}
@media (prefers-reduced-motion: reduce){
*{transition:none !important}
}
</style>
</head>
<body>
<header class="topbar">
<a class="brand" href="/"><span class="dot"></span><span class="name">The&nbsp;Booth</span></a>
<span class="tagline">ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't</span>
</header>
<main>{% block content %}{% endblock %}</main>
<script>
/* The sticky rail's real height, published as --rail-h for .item's
scroll-margin. ADDITIVE: with JS off the static fallback applies, and a
page without a rail does nothing. Re-measured whenever the rail's height
changes, so a rail that wraps differently at another width stays right. */
(function () {
var rail = document.querySelector('.rail');
if (!rail) return;
function set() {
document.documentElement.style.setProperty('--rail-h', rail.offsetHeight + 'px');
}
set();
if (window.ResizeObserver) new ResizeObserver(set).observe(rail);
})();
</script>
<footer class="foot">
drop a folder into <code>{{ data_dir }}</code>{% if host %} · {{ host }}{% endif %}
</footer>
</body>
</html>