/* gallery.css — the whole look of the published gallery.
 *
 * No external stylesheet, no web font, no CDN: GitHub Pages serves this file
 * and nothing else is fetched for the chrome. The only network dependency in
 * the site is p5 inside a sketch's own page, which is the sketch's business.
 *
 * Both themes: the palette is defined once on :root and redefined under
 * prefers-color-scheme: dark. Nothing else in this file names a colour.
 */

:root {
  --ink: #16181d;
  --ink-soft: #4a5160;
  --ground: #f7f7f5;
  --panel: #ffffff;
  --line: #d9dae0;
  --accent: #2f5bd8;
  --agent: #b8741a;
  --chip: #eceef4;
  --failed-ink: #8a2b18;
  --failed-ground: #fbe9e4;
  --ok-ink: #1f6b3f;
  --ok-ground: #e4f2e8;
  --shadow: 0 1px 2px rgba(20, 22, 28, 0.08);
}

@media (prefers-color-scheme: dark) {
  :root {
    --ink: #e9eaee;
    --ink-soft: #a3a9b8;
    --ground: #14161a;
    --panel: #1c1f25;
    --line: #2e333c;
    --accent: #8fabff;
    --agent: #e0a85a;
    --chip: #262b33;
    --failed-ink: #ffb4a0;
    --failed-ground: #35201a;
    --ok-ink: #7fd6a2;
    --ok-ground: #1a3023;
    --shadow: none;
  }
}

* { box-sizing: border-box; }

body {
  margin: 0;
  padding: 0 1.25rem 4rem;
  background: var(--ground);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  font-size: 16px;
  line-height: 1.5;
}

a { color: var(--accent); }

h1 { font-size: 1.6rem; line-height: 1.25; margin: 1.5rem 0 0.25rem; }
h2 { font-size: 1.05rem; margin: 0 0 0.5rem; }

.bar {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  justify-content: space-between;
  align-items: baseline;
  border-bottom: 1px solid var(--line);
  margin: 0 auto;
  max-width: 60rem;
  padding: 0.9rem 0;
}

footer.bar { border-bottom: 0; border-top: 1px solid var(--line); margin-top: 2.5rem; }

.wordmark { font-weight: 600; margin: 0; }
.wordmark a { color: inherit; text-decoration: none; }
.tagline { margin: 0; font-size: 0.85rem; opacity: 0.75; flex: 1 1 100%; }
.nav { margin: 0; color: var(--ink-soft); }
/* Filled by gallery.js from /me; hidden until someone is actually signed in. */
.session { margin: 0; font-size: 0.85rem; color: var(--ink-soft); }
.session[hidden] { display: none; }

main {
  margin: 0 auto;
  max-width: 60rem;
}

.byline, .intro { color: var(--ink-soft); margin: 0.25rem 0 1.25rem; }

/* The title is the root prompt; this is the revision that made this entry
 * rather than its parent. The label is mono because it is the heading the
 * prompt itself carries, and the revision is italic because it is quoted. */
.sub { margin: 0.35rem 0 0.6rem; }
.sub .label {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.85rem;
  letter-spacing: 0.02em;
}
.sub em { font-style: italic; }
.sub .dim { margin-left: 0.15rem; }
.sub .chip { margin: 0 0.15rem; vertical-align: 0.05em; }
h1 + .sub + .byline { margin-top: 0; }
.note { color: var(--ink-soft); font-size: 0.85rem; margin: 0.5rem 0 0; }
.none { color: var(--ink-soft); font-style: italic; }

.panel, .stage, .engagement, .questions, .reveal, .filters, .compose {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: var(--shadow);
  margin: 0 0 1rem;
  padding: 1rem;
}

.stage { padding: 0.75rem; }
.stage-meta { color: var(--ink-soft); font-size: 0.85rem; margin: 0.6rem 0 0; }

iframe.sketch {
  background: var(--chip);
  border: 0;
  border-radius: 6px;
  display: block;
  height: 26rem;
  width: 100%;
}

.chip {
  background: var(--chip);
  border-radius: 999px;
  display: inline-block;
  font-size: 0.75rem;
  letter-spacing: 0.03em;
  padding: 0.1rem 0.55rem;
  text-transform: uppercase;
}

.chip.failed { background: var(--failed-ground); color: var(--failed-ink); }
/* An operator rejection wears the same ground as the gate's: both are
 * rejections and neither is a lesser one. The chip's own text is what
 * separates them - "rejected . gate" against "rejected . operator" - so the
 * two read as lowercase phrases rather than stamps, which is also the only
 * way the middle dot survives being uppercased. */
.chip.rejected { background: var(--failed-ground); color: var(--failed-ink); }
.chip.failed, .chip.rejected { letter-spacing: 0; text-transform: none; }
p.chip.rejected { text-transform: none; letter-spacing: 0; border-radius: 8px; padding: 0.5rem 0.75rem; }
/* Off-plan is NOT a rejection and must not wear the rejection ground. The
 * sketch ran: it threw nothing, it did not freeze, it stayed inside the frame
 * budget. It differs from a brief another model wrote, which is a description
 * of the work and not a verdict on it, so it takes the neutral chip ground the
 * page uses for facts. */
.chip.offplan { background: var(--chip); color: var(--ink-soft); }
p.chip.offplan { text-transform: none; letter-spacing: 0; border-radius: 8px; padding: 0.5rem 0.75rem; }
/* Off-node: a model that made this entry ran somewhere other than this node
 * (a paid model, answered through `sketchgen paid`). A fact about provenance,
 * not a verdict, so it takes the agent's ink on the neutral ground. */
.chip.offnode { color: var(--agent); }
.chip.agree { background: var(--ok-ground); color: var(--ok-ink); }
/* A critic chip names who asked for the revision: a model in the agent
 * colour, a person in the ok colour, the same two colours the scores use. */
.chip.model { color: var(--agent); letter-spacing: 0; text-transform: none; }
.chip.person { background: var(--ok-ground); color: var(--ok-ink); letter-spacing: 0; text-transform: none; }
.chip.disagree { background: var(--failed-ground); color: var(--failed-ink); }
/* The agreement chips sit in the card's meta line among lowercase tags, and
 * read as a sentence about the entry rather than as a stamp on it. */
.chip.agree, .chip.disagree, .chip.only { letter-spacing: 0; text-transform: none; }
p.chip.failed { text-transform: none; letter-spacing: 0; border-radius: 8px; padding: 0.5rem 0.75rem; }

.two { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
.score { background: var(--chip); border-radius: 8px; padding: 0.75rem; }
.score-label { font-weight: 600; margin: 0; }
.score-value { font-size: 1.2rem; margin: 0.2rem 0 0; }
.score-brief { color: var(--ink-soft); font-size: 0.85rem; margin: 0.15rem 0 0; }
.agent-verdict { font-size: 0.9rem; margin: 0.2rem 0 0; }

/* Said only in the reveal, never beside the sketch: spec §5 blinds the human
 * until both answers are in, so a rejection looks like any other side first. */
.rejected-note {
  background: var(--failed-ground);
  border-radius: 8px;
  color: var(--failed-ink);
  font-size: 0.9rem;
  margin: 0 0 0.5rem;
  padding: 0.4rem 0.6rem;
}

.actions { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin: 0.75rem 0 0; }
.counts { font-size: 1rem; margin: 0; }

button, .button {
  background: var(--chip);
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--ink);
  cursor: pointer;
  font: inherit;
  padding: 0.4rem 0.9rem;
  text-decoration: none;
}

button[disabled] { cursor: not-allowed; opacity: 0.55; }
button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }

table.fields { border-collapse: collapse; width: 100%; }
table.fields th, table.fields td {
  border-top: 1px solid var(--line);
  padding: 0.45rem 0.5rem;
  text-align: left;
  vertical-align: top;
  word-break: break-word;
}
table.fields th { color: var(--ink-soft); font-weight: 500; white-space: nowrap; width: 11rem; }

/* The entry's own QR code, at the foot of the Source panel (qr.md §6.1). The
 * white tile is not decoration: this theme is dark, and a transparent code on
 * a dark ground is an inverted code that scanners differ on. */
.qr { align-items: center; display: flex; gap: 1rem; margin: 1rem 0 0; }
.qr img { background: #fff; border-radius: 0.4rem; display: block; padding: 0.5rem; }
.qr figcaption { color: var(--ink-soft); font-size: 0.85rem; word-break: break-word; }

/* The strip gallery.js reveals for somebody who arrived by scanning a
 * projection (§6.2). It is a table of contents for a page a stranger has
 * thirty seconds of patience for, and it links to controls this page already
 * has; hidden until the script sees ?kiosk, and nothing in it is built from
 * a URL parameter. */
.scanned {
  background: var(--chip);
  border: 1px solid var(--line);
  border-radius: 10px;
  font-size: 0.9rem;
  margin: 0 0 1rem;
  padding: 0.7rem 1rem;
}
.scanned[hidden] { display: none; }

/* The filters fold away: <details> collapsed until someone opens it, or until
 * gallery.js sees a filter in the URL. A block, not a flex row, because a
 * summary is only reliably a disclosure while it is its parent's first flow
 * child. */
.filters { display: block; }
.filter-label, .sort-label { color: var(--ink-soft); margin: 0; }
summary.filter-label { cursor: pointer; list-style: none; }
summary.filter-label::-webkit-details-marker { display: none; }
summary.filter-label::before { content: "\25b8\a0"; }
.filters[open] > summary.filter-label::before { content: "\25be\a0"; }
.filters[open] .filter-links { margin-top: 0.7rem; }
.filter-links, .sort-buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0; }

/* The sort buttons carry the same weight as a filter chip; they are buttons
 * and not links because they reorder the page in place. With no JavaScript
 * they sit there inert and the grid is newest first, which is what they say. */
.sorts { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: baseline; margin: 0 0 1rem; }
.filter, .sort { background: var(--chip); border-radius: 999px; padding: 0.15rem 0.7rem; text-decoration: none; }
.sort { border: 1px solid transparent; color: var(--ink); font: inherit; line-height: inherit; }
.filter[aria-current="true"], .sort[aria-current="true"] { background: var(--accent); color: var(--panel); }

/* The search box rides at the far end of the sort row, a chip's height so the
 * row stays one row. It narrows the grid; the sort reorders it; neither one
 * knows about the other. */
.search { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; margin-left: auto; }
.search-label { color: var(--ink-soft); }
.search input {
  background: var(--chip);
  border: 1px solid var(--line);
  border-radius: 999px;
  color: inherit;
  font: inherit;
  line-height: inherit;
  min-width: 12rem;
  padding: 0.15rem 0.7rem;
}
.search-count { color: var(--ink-soft); font-size: 0.85rem; }

/* The two forms a signed-in visitor gets: the composer, above the grid in the
 * space the filter bar gave up, and the critique form on an entry page. They
 * share every control because they ask for the same thing — one sentence, no
 * code — in two places.
 *
 * Every state of each is in the HTML and hidden; gallery.js reveals one of
 * them once /me has answered. Nothing below sets `display` on a block the
 * script hides, so `hidden` stays the whole of that decision. */
.compose-head {
  align-items: baseline;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 0.75rem;
  justify-content: space-between;
  margin: 0 0 0.6rem;
}
.compose-head h2 { margin: 0; }
.quota { color: var(--ink-soft); font-size: 0.85rem; margin: 0; }
.quota b { color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 600; }
.signed-out-line { color: var(--ink-soft); margin: 0; }

textarea {
  background: var(--ground);
  border: 1px solid var(--line);
  border-radius: 8px;
  color: inherit;
  display: block;
  font: inherit;
  line-height: 1.45;
  padding: 0.55rem 0.7rem;
  resize: vertical;
  width: 100%;
}

/* The contract, stated while someone types against it: what the label says
 * before a keystroke, what the validator says after one. */
.rule { color: var(--ink-soft); font-size: 0.82rem; margin: 0.45rem 0 0; }
.rule.bad { color: var(--failed-ink); }
.rule.good { color: var(--ok-ink); }
label.rule { display: block; margin: 0 0 0.35rem; }

button.primary { background: var(--accent); border-color: var(--accent); color: var(--panel); }

.receipt {
  background: var(--ok-ground);
  border-radius: 8px;
  color: var(--ok-ink);
  margin: 0;
  padding: 0.7rem 0.85rem;
}
.receipt p { margin: 0; }
.receipt p + p { font-size: 0.85rem; margin-top: 0.35rem; }

/* The prompt the child would carry, in the shape .sub renders a revision in on
 * the entry page above: the sentences it inherits, then the one being typed. */
.preview {
  background: var(--ground);
  border: 1px solid var(--line);
  border-left: 3px solid var(--agent);
  border-radius: 6px;
  margin: 0.75rem 0 0;
  padding: 0.6rem 0.75rem;
}
.preview .label {
  color: var(--ink-soft);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.preview p { font-size: 0.9rem; margin: 0.3rem 0 0; }
.preview .revise { color: var(--ink-soft); }
.preview .revise .label { font-size: 0.85rem; letter-spacing: 0.02em; text-transform: none; }
.preview .revise em { color: var(--ink); font-style: italic; }

.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); }
/* The index's pages (2026-09-23): plain links, so browsing needs no script. */
.pager { margin: 1.5rem 0 0; text-align: center; }
.pager .off { opacity: 0.4; }

.card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: var(--shadow);
  overflow: hidden;
  padding: 0 0 0.55rem;
}

.card .thumb { margin: 0; }
.card img { display: block; width: 100%; background: var(--chip); }
.card-prompt {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  font-size: 0.85rem;
  margin: 0.45rem 0.6rem 0.2rem;
  max-height: 2.6rem;
  overflow: hidden;
}
.card-prompt a { color: inherit; text-decoration: none; }
/* The card's second line: the generation and the revision behind it, so the
 * grid shows what changed and not the root prompt over and over. */
.card-revision {
  color: var(--ink-soft);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  font-size: 0.78rem;
  margin: 0 0.6rem 0.2rem;
  max-height: 2.4rem;
  overflow: hidden;
}
.card-meta, .card-counts, .reason {
  color: var(--ink-soft);
  font-size: 0.8rem;
  margin: 0.15rem 0.6rem 0;
}
.card-meta:empty { display: none; }
.reason { color: var(--failed-ink); }
.tag { background: var(--chip); border-radius: 999px; padding: 0.05rem 0.5rem; }

/* The standing bars. A track is the pool of entries one population has scored
 * on one question, weakest at the left and strongest at the right; a mark is
 * this entry's place in it. The filled dot is the humans, the ring is the
 * agents, and the bar between them is how far apart the two readings are.
 *
 * Opacity is evidence, not decoration: a mark drawn on a single pair is nearly
 * a guess and is drawn faint, one drawn on eight pairs or more is solid. Only
 * `left` and `opacity` are set per mark, on the mark itself, because only they
 * differ from card to card; everything else about the look is here. */
.standing { display: flex; flex-direction: column; gap: 0.3rem; margin: 0.35rem 0.6rem 0; }
.bar-row {
  align-items: center;
  color: var(--ink-soft);
  display: grid;
  font-size: 0.8rem;
  gap: 0.6rem;
  grid-template-columns: 8.4rem 1fr;
}
.track {
  background: linear-gradient(90deg, var(--chip), var(--line));
  border-radius: 999px;
  height: 10px;
  position: relative;
}
.mark {
  border-radius: 50%;
  box-sizing: border-box;
  height: 12px;
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 12px;
}
/* The ring is hollow rather than filled with the panel colour so that the two
 * populations landing on the same percentile still read as two marks: the
 * humans' dot shows through the agents' ring instead of disappearing under it. */
.mark.human { background: var(--accent); }
.mark.agent { background: none; border: 2.5px solid var(--agent); }
.legend .mark.agent { background: var(--panel); }
.mark.inline {
  display: inline-block;
  height: 10px;
  position: static;
  transform: none;
  vertical-align: middle;
  width: 10px;
}
.gap {
  background: var(--ink-soft);
  height: 3px;
  opacity: 0.45;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}
.none-note { color: var(--ink-soft); font-size: 0.75rem; left: 0; position: absolute; top: -4px; }
.legend { color: var(--ink-soft); font-size: 0.72rem; margin: 0.15rem 0 0; }
.legend .mark.inline { margin-right: 0.2rem; }
.legend .mark.inline ~ .mark.inline { margin-left: 0.5rem; }

/* The entry page's compass. The same percentiles as the card's bars, but both
 * at once on one square: across is "rather look at it", up is "closer to its
 * brief". The faint centre lines cut it into the four readings the words
 * beside it name — top right liked and on brief, bottom right liked but off
 * brief, top left on brief with little to look at, bottom left neither.
 *
 * A population is drawn only when it has scored the entry on BOTH questions:
 * one coordinate is not a point, and supplying the other from nothing would
 * put the entry somewhere nobody voted for. So a square holds two marks, one,
 * or none, and the line between two of them is the same disagreement the bars
 * draw. Radius and opacity are the evidence and are set per mark, because only
 * they differ from entry to entry; the rest of the look is here. */
.compass-wrap { align-items: flex-start; display: flex; gap: 0.9rem; margin: 0 0 0.9rem; }
.compass { flex: none; }
.c-ground { fill: var(--chip); stroke: var(--line); }
.c-axis { stroke: var(--line); stroke-width: 1; }
.c-gap { opacity: 0.45; stroke: var(--ink-soft); stroke-width: 2; }
/* Unfilled for the same reason the card's ring is: two populations that land
 * on the same point still read as two marks, the dot inside the ring. */
.c-mark.human { fill: var(--accent); }
.c-mark.agent { fill: none; stroke: var(--agent); stroke-width: 2.5; }
.c-none { fill: var(--ink-soft); font-size: 12px; }
.quads { display: flex; flex-direction: column; font-size: 0.85rem; gap: 0.25rem; }
.q.human { color: var(--accent); }
.q.agent { color: var(--agent); }
.q.none { color: var(--ink-soft); }
.axes { color: var(--ink-soft); font-size: 0.75rem; margin-top: 0.2rem; }

.pair { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); margin-bottom: 1rem; }
.side { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 1rem; }
.side-label { font-size: 1.3rem; font-weight: 600; margin: 0 0 0.5rem; }
.side img { border-radius: 6px; display: block; width: 100%; }
.side .thumb { margin: 0; }

/* The strip is a button: clicking it runs that sketch here, in place. Stripped
 * back to the image it wraps, so it still reads as the thumbnail it replaced. */
.side .play {
  background: none;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
  display: block;
  padding: 0;
  width: 100%;
}
.side .play:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* While it runs, that same button is the stop control under the frame. */
.side .play.running {
  background: var(--chip);
  border: 1px solid var(--line);
  margin: 0.5rem 0 0;
  padding: 0.25rem 0.8rem;
  width: auto;
}
.side .play.running img { display: none; }
/* A fixed box, so swapping strip for sketch and back does not move the
 * questions underneath. 64:45 is the gate's viewport, 1280 by 900 (sketch_gate.py
 * VIEWPORT), which is also each frame of the strip the iframe replaces. The
 * global iframe.sketch height is for the entry page. */
.side iframe.sketch { aspect-ratio: 64 / 45; height: auto; width: 100%; }
.run-note { color: var(--ink-soft); font-size: 0.85rem; margin: 0.4rem 0 0; }
.run-note:empty { margin: 0; }

.side-brief { color: var(--ink-soft); }
.question { border-top: 1px solid var(--line); padding: 0.75rem 0 0; }
.question:first-child { border-top: 0; padding-top: 0; }
.ask { font-weight: 600; margin: 0 0 0.5rem; }
.choices { display: flex; gap: 0.5rem; margin: 0; }
.answered { color: var(--ink-soft); font-size: 0.85rem; margin: 0.4rem 0 0; }

.tree .node { border-left: 2px solid var(--line); margin: 0.5rem 0 0 0; padding: 0 0 0 0.9rem; }
.tree .depth-0 { border-left: 0; padding-left: 0; }
.node-head { margin: 0; }
.node-prompt { color: var(--ink-soft); margin: 0 0 0.3rem; }

.critique { color: var(--ink); font-style: italic; margin: 0 0 0.3rem; }
.dim { color: var(--ink-soft); font-style: normal; font-size: 0.85rem; }
.node.waits { border-left-style: dashed; }

/* The ledger — the entry page's lineage panel (plan §6).
 *
 * One row per generation, oldest at the top: the frame that generation
 * produced on the left, the critique that asked for it on the right. It is a
 * two-column grid at every width, because the alternative on a phone is a
 * column of images with their captions a screen away from them; only the tile
 * narrows. Nothing in here scrolls sideways.
 *
 * Every tile is the first frame of that entry's strip.png — cover, anchored
 * left, on the gate's own 64:45 box — so the ledger costs no new image files.
 */
.ledger { list-style: none; margin: 0; padding: 0; }

.ledger-row {
  align-items: start;
  border-radius: 8px;
  display: grid;
  gap: 0.8rem;
  grid-template-columns: max-content 1fr;
  padding: 0.4rem;
}
/* This entry, in its own line: the ring is what the eye finds first, and the
 * row is the one that is not a link, because it is already here. */
.ledger-row.here {
  background: var(--chip);
  box-shadow: inset 0 0 0 2px var(--accent);
}

.ledger-tile { position: relative; }
.ledger-tile.narrow { width: 6.5rem; }
.ledger-tile.wide { width: 100%; }
.ledger-tile img, .ledger-tile.blank {
  aspect-ratio: 64 / 45;
  background: var(--chip);
  border-radius: 6px;
  display: block;
  object-fit: cover;
  object-position: left;
  width: 100%;
}
/* A generation with no public page still gets its place in the column, so the
 * rows stay aligned and the count stays right. */
.ledger-tile.blank { border: 1px dashed var(--line); }
.ledger-tile .play {
  background: none;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
  display: block;
  padding: 0;
  width: 100%;
}
.ledger-tile .play:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* While it runs, the same button is a stop control lying over the frame's
 * corner rather than a box under it: the row must not change height when the
 * sketch starts, or everything below it jumps. */
.ledger-tile .play.running {
  background: var(--chip);
  border: 1px solid var(--line);
  border-radius: 999px;
  bottom: 0.3rem;
  font-size: 0.7rem;
  left: 0.3rem;
  padding: 0 0.5rem;
  position: absolute;
  width: auto;
  z-index: 1;
}
.ledger-tile .play.running img { display: none; }
.ledger-tile iframe.sketch { aspect-ratio: 64 / 45; height: auto; width: 100%; }

.ledger-text { min-width: 0; }
.ledger-prompt { font-weight: 600; margin: 0 0 0.15rem; }
.ledger-critique { margin: 0 0 0.15rem; }
.ledger-critique .revise {
  color: var(--ink-soft);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.8rem;
}
/* Full length in the ledger — the critique is the reason the generation
 * exists — and three lines in the descendants grid, where it is a caption. */
.ledger-critique.clamp {
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  display: -webkit-box;
  line-clamp: 3;
  overflow: hidden;
}
.ledger-meta { color: var(--ink-soft); font-size: 0.85rem; margin: 0; }

/* Who asked for this generation: a model in the agent colour, a person in the
 * ok colour, the same two colours the rest of the site uses for the two
 * populations. Lowercase, because these are names and not stamps. */
.chip.model, .chip.person, .chip.unpublished, .chip.heavy {
  letter-spacing: 0;
  text-transform: none;
}

/* The middle of a long line, behind a disclosure that opens without script. */
.ledger-folded { margin: 0.1rem 0; }
summary.fold-label {
  color: var(--ink-soft);
  cursor: pointer;
  font-size: 0.85rem;
  list-style: none;
  padding: 0.2rem 0.4rem;
}
summary.fold-label::-webkit-details-marker { display: none; }
summary.fold-label::before { content: "\25b8\a0"; }
details.fold[open] > summary.fold-label::before { content: "\25be\a0"; }
.ledger-folded .ledger {
  border-left: 2px solid var(--line);
  margin-left: 0.7rem;
  padding-left: 0.5rem;
}

/* A fork: the other children of this entry's parent, indented under it. */
.ledger-forks {
  border-left: 2px solid var(--line);
  margin: 0 0 0.4rem 1.2rem;
  padding-left: 0.6rem;
}
.ledger-fork-label { color: var(--ink-soft); font-size: 0.85rem; margin: 0.2rem 0 0; }

.ledger-rule { border: 0; border-top: 1px solid var(--line); margin: 0.9rem 0 0.6rem; }
.ledger-after { font-weight: 600; margin: 0 0 0.6rem; }
.ledger-grid {
  display: grid;
  gap: 0.8rem;
  grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
}
.ledger-card .ledger-text { margin-top: 0.35rem; }
.ledger-more, .ledger-plain { color: var(--ink-soft); font-size: 0.85rem; margin: 0.5rem 0 0; }

/* An entry the gate had to grind through does not start itself; its stage is
 * the strip and a play button, run by the same helper the ledger uses. */
.stage-run { margin: 0; position: relative; }
.stage-run img { border-radius: 6px; display: block; width: 100%; }
.stage-run .play {
  background: none;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
  display: block;
  padding: 0;
  width: 100%;
}
.stage-run .play:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.stage-run .play.running {
  background: var(--chip);
  border: 1px solid var(--line);
  margin: 0.5rem 0 0;
  padding: 0.25rem 0.8rem;
  width: auto;
}
.stage-run .play.running img { display: none; }
.stage-run iframe.sketch { aspect-ratio: 64 / 45; height: auto; width: 100%; }
.stage-heavy { color: var(--ink-soft); font-size: 0.85rem; margin: 0.6rem 0 0; }
/* A sketch that fetches a photograph makes the reader's own browser call a
 * host that is not this gallery, so the page says which one before they press
 * play (media-assertion.md §4). Only entries gated under `loads(image)` have
 * the line, so this selector is unused on most pages and costs them nothing. */
.stage-loads { color: var(--ink-soft); font-size: 0.85rem; margin: 0.6rem 0 0; }
/* A microphone sketch opens in its own tab — the sandboxed frame cannot reach
 * the mic — so its stage is a link, with the label as an accent tag under it. */
.stage-mic .play { text-decoration: none; }
.stage-mic .play-label {
  display: inline-block; margin-top: 0.5rem; color: var(--accent); font-size: 0.9rem;
}
.stage-mic .run-note { color: var(--ink-soft); font-size: 0.85rem; margin: 0.3rem 0 0; }
/* The gate's ghost window: four frames of the sketch under a pointer nobody
 * was holding, under the stage it belongs to (auto-mouse.md §5.3). Only the
 * entries gated since 2026-09-21 have one, so this selector is unused on most
 * pages and costs them nothing. */
figure.ghost { margin: 0.75rem 0 0; }
figure.ghost img { border-radius: 6px; display: block; width: 100%; }
figure.ghost figcaption {
  color: var(--ink-soft); font-size: 0.85rem; margin: 0.3rem 0 0;
}

@media (max-width: 40rem) {
  .ledger-tile.narrow { width: 5.5rem; }
  /* Two columns rather than one: a single column of tiles this wide is a
   * scroll, and the grid is meant to be scanned. */
  .ledger-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .ledger-forks { margin-left: 0.4rem; padding-left: 0.5rem; }
  .ledger-folded .ledger { margin-left: 0.3rem; padding-left: 0.4rem; }
}

/* ---- kiosk ---------------------------------------------------------------
 *
 * kiosk.html and nothing else: every rule below is scoped to body.kiosk, which
 * only that page carries, so none of it reaches the grid, an entry page or the
 * compare page. The stylesheet is the mockup's
 * (docs/plans/kiosk-mockup/kiosk.html) lifted whole, with four of its
 * selectors — .stage, .score, .note and .card — already spoken for above;
 * scoping is what keeps them apart, so the class names the template writes are
 * the mockup's unchanged, and the four are reset where they are used rather
 * than renamed. The mockup's <div class="kiosk"> is .room here, because the
 * class on the body is what does the scoping.
 *
 * One theme, on purpose (spec §1.11): a projector has no light mode. The
 * palette is the gallery's own dark theme with the ground pushed to true black
 * so letterbox bars vanish against a projector's off-state, and it is set on
 * body.kiosk rather than on :root so prefers-color-scheme cannot undo it.
 */

body.kiosk {
  --ground: #050608;
  --ink: #ecedef;
  --ink-soft: #a3a9b8;
  --ink-faint: #6b7180;
  --accent: #8fabff;
  --agent: #e0a85a;
  --line: rgba(236, 237, 239, 0.14);
  --scrim: rgba(5, 6, 8, 0.78);
  --scrim-soft: rgba(5, 6, 8, 0.55);
  --key-ground: rgba(236, 237, 239, 0.09);
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
  --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  /* Not used by any rule below, but the page still carries the shared header
   * and footer: left at the light theme's white, a panel or a chip inheriting
   * it would be a white slab in a dark room. */
  --panel: #14161a;
  --chip: #262b33;
  --shadow: none;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 16px;
  line-height: 1.45;
  margin: 0;
  overflow: hidden;
  padding: 0 1.25rem;
}

/* The bars are in the DOM so the page is still the gallery with JavaScript
 * off and so a viewer knows where they are; the start card covers them, and
 * the moment it is dismissed they go (spec §3). */
body.kiosk.playing .bar { display: none; }
body.kiosk.idle { cursor: none; }
/* An unattended wall (docs/plans/kiosk-mac.md §1.1): the frame is an opaque
 * origin, so over it the cursor is the sketch's to draw and its mouse moves
 * never reach this page. Taking the pointer off the frame puts the whole
 * stage under the rule above. The ghost pointer is dispatched inside the
 * frame by its own shim and does not notice; a visitor with a real mouse can
 * no longer click a sketch, which on a wall with no mouse is nothing. */
body.kiosk.unattended .stage iframe.sketch { pointer-events: none; }

/* ---- the room ---- */
body.kiosk .room {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  grid-template-rows: minmax(0, 1fr);
  inset: 0;
  position: fixed;
}

/* Pinned to the middle column, so a hidden side column gives its width to the
 * stage instead of shoving the sketch left. */
body.kiosk .stage {
  align-items: center;
  background: transparent;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  display: grid;
  grid-column: 2;
  justify-items: center;
  margin: 0;
  max-width: none;
  min-height: 0;
  min-width: 0;
  overflow: hidden;
  padding: 0;
  position: relative;
}

/* The frame carries no inline style of its own: kiosk.js puts the sketch's own
 * canvas size on the stage as --frame-w and --frame-h, and a row with no canvas
 * in the manifest leaves them unset, which is a frame that fills the stage.
 *
 * The frame is laid out at the sketch's size and then *scaled* (spec §4.3,
 * amended by kiosk-fullscreen.md §2). Sizing the frame alone never worked: a
 * sandboxed p5 canvas is whatever createCanvas() asked for and does not grow
 * with its frame, so a 400x400 sketch in a fitted 900x900 frame stayed 400x400
 * in the frame's top-left corner. A transform scales what the sketch actually
 * drew, so the pixels on the projector are the sketch's own composition, only
 * bigger. --frame-sx and --frame-sy are what the Z key changes; they are two
 * numbers rather than one so that `stretch` can pull the axes apart.
 *
 * No max-width or max-height: the scale is the fit, and a clamp on the layout
 * size would make the frame smaller than the number --frame-sx was computed
 * against, which is how a sketch ends up mis-sized instead of merely soft. */
body.kiosk .stage iframe.sketch {
  aspect-ratio: auto;
  background: #000;
  border: 0;
  border-radius: 0;
  display: block;
  height: var(--frame-h, 100%);
  transform: scale(var(--frame-sx, 1), var(--frame-sy, 1));
  /* The stage centres the frame, so the frame grows from its middle and a
   * `fill` crop takes the same amount off both edges. */
  transform-origin: center center;
  transition: opacity 420ms ease;
  width: var(--frame-w, 100%);
}

body.kiosk .caption { transition: opacity 420ms ease; }
body.kiosk .stage.fading iframe.sketch,
body.kiosk .stage.fading .caption { opacity: 0; }

/* time left on this sketch */
body.kiosk .progress {
  background: var(--accent);
  height: 2px;
  left: 0;
  position: fixed;
  top: 0;
  transition: width 250ms linear;
  width: 0;
  z-index: 5;
}
body.kiosk .progress.paused { background: var(--ink-faint); }

/* top-right status: place in the sequence, paused */
body.kiosk .status {
  color: var(--ink-faint);
  font-size: 0.78rem;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.04em;
  margin: 0;
  position: fixed;
  right: 1rem;
  text-transform: uppercase;
  top: 0.8rem;
  z-index: 4;
}
body.kiosk .status .paused { color: var(--ink); }
body.kiosk .status .hint { margin-left: 1em; }
body.kiosk .status .hint kbd { font-size: 0.7rem; padding: 0 0.35em; }

/* ---- caption block over the bottom of the stage ---- */
/* Two parts side by side (qr.md §5.2): the QR block at the lower left, the
 * words to the right of it. A row rather than a second box overlaid on the
 * stage, so that nothing can ever sit on top of anything — the caption
 * already has the scrim, is already pointer-events: none, and is already the
 * thing H hides.
 *
 * The order is a CSS concern and not a markup one on purpose. captionShell()
 * emits the words first and the code second, because the words are the
 * content and the code is an aria-hidden tile with three short lines beside
 * it — not the first thing a screen reader or a text browser should meet.
 * `order: -1` moves the tile to the eye's starting corner without moving it
 * in the reading order, so do not "tidy" this by swapping the two in the
 * script. And when the code is off, qrHtml() returns nothing at all, so the
 * words snap back flush left with no rule needed for the empty case. */
body.kiosk .caption {
  align-items: flex-end;
  background: linear-gradient(to top, var(--scrim-soft), rgba(5, 6, 8, 0));
  bottom: 0;
  display: flex;
  gap: 2rem;
  left: 0;
  max-width: 62rem;
  padding: 3rem 2rem 1.5rem;
  pointer-events: none;
  position: absolute;
  right: 0;
}
body.kiosk .caption:empty { display: none; }
body.kiosk .caption .words { display: grid; flex: 1 1 auto; gap: 0.45rem; min-width: 0; }
body.kiosk .caption .qr { display: grid; flex: 0 0 auto; gap: 0.3rem; order: -1; }

/* A phone reads a code from roughly ten times its width. At 13vh on a
 * two-metre projection the tile is about 25 cm, so this works from two or
 * three metres and not from the back of a lecture hall — which is why the URL
 * is printed as text under it as well. Aim the projector accordingly. */
body.kiosk .caption .qr .code {
  background: #fff;
  border-radius: 0.4rem;
  display: block;
  height: auto;
  padding: 0.6rem;
  width: clamp(7rem, 13vh, 12rem);
}
body.kiosk .caption .qr p {
  color: var(--ink-soft);
  font-size: 0.8rem;
  letter-spacing: 0.03em;
  margin: 0;
  max-width: 13rem;
  overflow-wrap: anywhere;
}
body.kiosk .caption .qr .scan { color: var(--ink); }
body.kiosk .prompt {
  font-size: clamp(1.25rem, 2.6vw, 2.2rem);
  font-weight: 500;
  line-height: 1.2;
  margin: 0;
  text-shadow: 0 1px 12px rgba(0, 0, 0, 0.6);
  text-wrap: balance;
}
body.kiosk .prompt .num {
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
  font-weight: 400;
  margin-right: 0.4em;
}
body.kiosk .revisions {
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  color: var(--ink-soft);
  display: -webkit-box;
  font-size: 0.95rem;
  margin: 0;
  overflow: hidden;
}
body.kiosk .revisions b { color: var(--ink); font-weight: 500; }
body.kiosk .authors { color: var(--ink-soft); font-size: 0.95rem; margin: 0; }
body.kiosk .authors span { color: var(--ink); }
body.kiosk .facts {
  color: var(--ink-soft);
  display: flex;
  flex-wrap: wrap;
  font-size: 0.8rem;
  font-variant-numeric: tabular-nums;
  gap: 0.35rem 1.1rem;
  letter-spacing: 0.03em;
  list-style: none;
  margin: 0;
  padding: 0;
}
body.kiosk .facts li b { color: var(--ink); font-weight: 500; }
body.kiosk .facts li.gen b { color: var(--accent); }
/* The ghost pointer's tag, in the colour swipe gives its touch facts: a
 * viewer who walks past a sketch that is moving by itself should be able to
 * tell that nobody is at the keyboard (docs/plans/auto-mouse.md §3.2). The
 * caption is pointer-events: none above and stays that way; this is a word,
 * not a control. */
body.kiosk .facts li.ghost { color: var(--agent); }

/* ---- side columns: code left, words right ---- */
body.kiosk .column {
  background: var(--scrim);
  border-left: 1px solid var(--line);
  display: flex;
  flex-direction: column;
  gap: 1.4rem;
  grid-column: 3;
  min-height: 0;
  overflow: hidden auto;
  padding: 1.5rem;
  scrollbar-width: none;
  width: clamp(18rem, 26vw, 34rem);
}
body.kiosk .column[hidden] { display: none; }
body.kiosk .column.code {
  border-left: 0;
  border-right: 1px solid var(--line);
  grid-column: 1;
  overflow: hidden;
  padding-right: 0;
  width: clamp(20rem, 30vw, 40rem);
}
body.kiosk .column h2 {
  color: var(--ink-faint);
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.1em;
  margin: 0 0 0.4rem;
  text-transform: uppercase;
}
body.kiosk .column p { color: var(--ink); font-size: 0.98rem; margin: 0; max-width: 62ch; }
body.kiosk .column .note {
  color: var(--ink-faint);
  font-size: 0.78rem;
  margin: 0.3rem 0 0;
}
body.kiosk .column section[hidden] { display: none; }

body.kiosk .judgment .pop {
  align-items: baseline;
  display: grid;
  gap: 0.25rem 0.75rem;
  grid-template-columns: 5.5rem 1fr;
}
body.kiosk .judgment .pop.agent { margin-top: 0.5rem; }
body.kiosk .judgment .pop-label {
  font-size: 0.8rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
body.kiosk .judgment .human .pop-label { color: var(--accent); }
body.kiosk .judgment .agent .pop-label { color: var(--agent); }
body.kiosk .judgment .score {
  background: none;
  border-radius: 0;
  font-size: 1.05rem;
  font-variant-numeric: tabular-nums;
  padding: 0;
}
body.kiosk .judgment .quad { color: var(--ink-soft); font-size: 0.85rem; grid-column: 2; }

body.kiosk .codewrap { flex: 1; min-height: 0; overflow: hidden; position: relative; }
body.kiosk .codewrap pre {
  color: var(--ink-soft);
  font-family: var(--mono);
  font-size: 0.74rem;
  line-height: 1.5;
  margin: 0;
  tab-size: 2;
  white-space: pre;
  will-change: transform;
}
body.kiosk .codewrap pre .ln {
  color: var(--ink-faint);
  display: inline-block;
  user-select: none;
  width: 2.6em;
}
body.kiosk .codewrap::after {
  background: linear-gradient(to bottom, rgba(5, 6, 8, 0), var(--ground));
  bottom: 0;
  content: "";
  height: 3rem;
  left: 0;
  position: absolute;
  right: 0;
}
body.kiosk .code h2 .file {
  color: var(--ink-soft);
  font-family: var(--mono);
  letter-spacing: 0;
  text-transform: none;
}

/* ---- the config menu: any key opens it ---- */
body.kiosk .menu {
  backdrop-filter: blur(6px);
  background: var(--scrim);
  border: 1px solid var(--line);
  border-radius: 6px;
  bottom: 3rem;
  display: grid;
  gap: 1rem 2rem;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  left: 50%;
  padding: 1.1rem 1.4rem 1rem;
  position: fixed;
  transform: translate(-50%, 0);
  transition: opacity 180ms ease, transform 180ms ease;
  width: min(60rem, calc(100vw - 2rem));
  z-index: 10;
}
body.kiosk .menu[hidden] { display: none; }
body.kiosk .menu h2 {
  align-items: baseline;
  color: var(--ink-faint);
  display: flex;
  font-size: 0.72rem;
  font-weight: 500;
  justify-content: space-between;
  letter-spacing: 0.1em;
  margin: 0 0 0.55rem;
  text-transform: uppercase;
}
body.kiosk .menu h2 .val {
  color: var(--ink);
  font-size: 0.85rem;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0;
  text-transform: none;
}
body.kiosk .rows { display: grid; gap: 0.3rem; list-style: none; margin: 0; padding: 0; }
body.kiosk .rows li {
  align-items: center;
  color: var(--ink-soft);
  display: grid;
  font-size: 0.9rem;
  gap: 0.6rem;
  grid-template-columns: 4.6rem 1fr auto;
}
body.kiosk .rows li.on { color: var(--ink); }
/* The one row that says what its next press would do, because "sketch size"
 * names four states and the key alone does not say which one is coming. It
 * sits inside the label column rather than in .state, which already holds the
 * state the row is in. */
body.kiosk .rows li .note {
  color: var(--ink-faint);
  display: block;
  font-size: 0.78rem;
  margin: 0;
}
body.kiosk .rows li .state {
  color: var(--ink-faint);
  font-size: 0.78rem;
  font-variant-numeric: tabular-nums;
}
body.kiosk .rows li.on .state { color: var(--accent); }
body.kiosk .rows li.current { color: var(--ink); }
body.kiosk .rows li.current .state { color: var(--accent); }
body.kiosk kbd {
  background: var(--key-ground);
  border: 1px solid var(--line);
  border-bottom-width: 2px;
  border-radius: 4px;
  color: var(--ink);
  font-family: var(--mono);
  font-size: 0.75rem;
  padding: 0.05em 0.45em;
  white-space: nowrap;
}
body.kiosk .keys { display: inline-flex; gap: 0.25rem; }
body.kiosk .menu footer {
  border-top: 1px solid var(--line);
  color: var(--ink-faint);
  display: flex;
  flex-wrap: wrap;
  font-size: 0.78rem;
  gap: 0.4rem 1.5rem;
  grid-column: 1 / -1;
  justify-content: space-between;
  padding-top: 0.7rem;
}
body.kiosk .menu footer code {
  color: var(--ink-soft);
  font-family: var(--mono);
  word-break: break-all;
}
body.kiosk .menu footer p { margin: 0; }

/* ---- first-run card: the one gesture that starts audio and fullscreen ---- */
body.kiosk .welcome {
  background: var(--ground);
  display: grid;
  inset: 0;
  place-items: center;
  position: fixed;
  z-index: 20;
}
body.kiosk .welcome[hidden] { display: none; }
/* .card is the grid's card above, which is a panel with a border and a
 * shadow. Here it is the start card and it is nothing but text on the ground. */
body.kiosk .welcome .card {
  background: none;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  max-width: 30rem;
  overflow: visible;
  padding: 0 1.5rem;
  text-align: center;
}
body.kiosk .welcome h1 { font-size: 1.2rem; font-weight: 500; margin: 0 0 0.4rem; }
body.kiosk .welcome p { color: var(--ink-soft); font-size: 0.95rem; margin: 0.3rem 0; }
body.kiosk .welcome .note { font-size: 0.8rem; margin-top: 1rem; }
body.kiosk .welcome .go {
  background: var(--key-ground);
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--ink);
  cursor: pointer;
  display: inline-block;
  font: inherit;
  margin-top: 1.2rem;
  padding: 0.6rem 1.2rem;
}
body.kiosk .welcome .go:focus-visible,
body.kiosk .welcome .go:hover { border-color: var(--accent); outline: none; }

@media (prefers-reduced-motion: reduce) {
  body.kiosk .stage iframe.sketch,
  body.kiosk .caption,
  body.kiosk .menu,
  body.kiosk .progress { transition: none; }
}

/* ---- phone width: columns stack under the stage ---- */
@media (max-width: 760px) {
  /* Stacked, the columns are in the page rather than pinned over it, so the
   * side padding that keeps the header bar off the edge would inset them. */
  body.kiosk { overflow: auto; padding: 0; }
  body.kiosk .room { display: block; min-height: 100dvh; position: static; }
  body.kiosk .stage { height: 62dvh; }
  body.kiosk .column,
  body.kiosk .column.code {
    border: 0;
    border-top: 1px solid var(--line);
    max-height: 60dvh;
    width: 100%;
  }
  body.kiosk .caption { padding: 2rem 1rem 1rem; }
  /* Stacked, the code goes above the words rather than under them: a
   * phone-shaped screen would otherwise push it off the bottom, and the code
   * is the one thing on this page a stranger can act on. Plain `column`, not
   * `column-reverse`: the `order: -1` that puts the tile at the left of the
   * row already puts it first in the column, and reversing on top of that
   * would send it back to the bottom. The two declarations are a pair. */
  body.kiosk .caption { flex-direction: column; align-items: stretch; }
  body.kiosk .menu {
    bottom: 1rem;
    grid-template-columns: 1fr;
    max-height: calc(100dvh - 2rem);
    overflow: auto;
  }
}

/* A local render (docs/plans/local-gallery.md §1.7). gallery.js draws these
 * only when config.json carries "local", which the published site's never
 * does: the banner says the page is not the site, and the toggles collect
 * ids for an import. */
.chip.held { background: var(--ok-ground); color: var(--ok-ink); }

.local-banner {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25rem 0.5rem;
  padding: 0.5rem 1rem;
  background: var(--chip);
  color: var(--ink);
  border-bottom: 1px solid var(--line);
  font-size: 0.9rem;
}
.local-banner textarea[hidden] { display: none; }
.local-banner textarea {
  flex-basis: 100%;
  font: inherit;
  font-family: ui-monospace, monospace;
}

.pick {
  font: inherit;
  font-size: 0.85rem;
  padding: 0.15rem 0.6rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--panel);
  color: var(--ink-soft);
  cursor: pointer;
}
.pick[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--panel);
}

/* ---- swipe ----------------------------------------------------------------
 *
 * swipe.html and nothing else: every rule below is scoped to body.swipe, which
 * only that page carries, so none of it reaches the grid, an entry page, the
 * compare page or the kiosk. The stylesheet is the mockup's
 * (docs/plans/swipe-mockup/swipe.html) lifted whole, with its .phone renamed
 * body.swipe — on a phone the mockup already drops the device frame and goes
 * fixed to the viewport, and that is the only state the real page has — and its
 * `position: absolute` layers made `position: fixed`, because they hang off the
 * viewport here rather than off a 24.5rem box in the middle of a document.
 *
 * Selectors the gallery already spends elsewhere — .stage, .caption, .status,
 * .score, .note, .card, .close, .links — are the mockup's unchanged; scoping is
 * what keeps them apart, exactly as it does for the kiosk block above.
 *
 * One theme, on purpose (spec §1.13): a sketch on black has no light mode. The
 * palette is the kiosk's, set on body.swipe rather than on :root so
 * prefers-color-scheme cannot undo it.
 *
 * The exception is .swipe-offer, at the bottom: it belongs to the grid, which
 * is the one page that offers this one.
 */

body.swipe {
  --ground: #050608;
  --ink: #ecedef;
  --ink-soft: #a3a9b8;
  --ink-faint: #6b7180;
  --accent: #8fabff;
  --agent: #e0a85a;
  --line: rgba(236, 237, 239, 0.14);
  --scrim: rgba(5, 6, 8, 0.82);
  --scrim-soft: rgba(5, 6, 8, 0.55);
  --key-ground: rgba(236, 237, 239, 0.09);
  --panel: #14161a;
  --chip: #262b33;
  --shadow: none;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
  --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 16px;
  line-height: 1.4;
  margin: 0;
  overflow: hidden;
  padding: 0 1.25rem;
  /* No swipe becomes a scroll and no pull becomes a refresh: the gestures are
   * the page's, and a browser that takes one of them takes the whole feed. */
  touch-action: none;
  overscroll-behavior: none;
  user-select: none;
  -webkit-user-select: none;
}
body.swipe a { color: var(--accent); }
body.swipe button { font: inherit; }

/* The bars are in the DOM so the page is still the gallery with JavaScript off
 * and so a viewer knows where they are; the start card covers them, and the
 * moment it is dismissed they go (spec §3). */
body.swipe.playing .bar { display: none; }

/* ---- the stage: the sketch, fitted, and the shield over it ---- */
/* .stage is a panel on the entry page — a bordered box with a shadow and a
 * rem of padding. Here it is the whole screen and nothing else, so the panel
 * is reset rather than renamed, as the kiosk block does for the same four
 * selectors. */
body.swipe .stage {
  background: none; border: 0; border-radius: 0; box-shadow: none; margin: 0; padding: 0;
  position: fixed; inset: 0; display: grid; place-items: center; overflow: hidden;
  /* One track the size of the stage, not an implicit one the size of the
   * unscaled frame: an 800 px frame in an 800 px track centres in the track,
   * and the track starts at the left edge, so the sketch sat off to the
   * right. The kiosk's stage is a track of its room's grid and never met this. */
  grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
  will-change: transform;
}
body.swipe .stage canvas { display: block; }
body.swipe .stage.snap { transition: transform 220ms cubic-bezier(0.2, 0.7, 0.2, 1); }
body.swipe .stage.leave { transition: transform 160ms cubic-bezier(0.4, 0, 1, 1); }
/* A finger that lands on the frame belongs to the frame, and there is no
 * channel to ask for it back (spec §1.4), so this takes every touch. */
/* The frame is the canvas's own size and the scale does the fitting: the
 * kiosk's rule (kiosk-fullscreen.md §1), rescoped. Without it the frame falls
 * through to the entry page's 26rem grey box. */
body.swipe .stage iframe.sketch {
  aspect-ratio: auto;
  background: #000;
  border: 0;
  border-radius: 0;
  display: block;
  height: var(--frame-h, 100%);
  transform: scale(var(--frame-sx, 1), var(--frame-sy, 1));
  transform-origin: center center;
  width: var(--frame-w, 100%);
}
body.swipe .shield { position: fixed; inset: 0; z-index: 3; cursor: grab; }
body.swipe .shield:active { cursor: grabbing; }
body.swipe.touching .shield { pointer-events: none; }

/* ---- the two cues that grow under a horizontal drag ---- */
body.swipe .cue {
  position: fixed; top: 50%; z-index: 4; transform: translateY(-50%) scale(0.85); opacity: 0;
  pointer-events: none; display: grid; place-items: center; gap: 0.3rem;
  transition: opacity 120ms, transform 120ms;
}
body.swipe .cue svg { width: 4.2rem; height: 4.2rem; filter: drop-shadow(0 2px 14px rgba(0, 0, 0, 0.7)); }
body.swipe .cue span {
  font-size: 0.72rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink);
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.8);
}
body.swipe .cue.like { left: 1.4rem; }
body.swipe .cue.judge { right: 1.4rem; }
body.swipe .cue.like svg { fill: var(--accent); }
body.swipe .cue.judge svg { fill: none; stroke: var(--ink); stroke-width: 2; }
body.swipe .cue.burst { animation: swipe-burst 700ms ease-out forwards; }
@keyframes swipe-burst {
  0% { opacity: 1; transform: translateY(-50%) scale(1); }
  100% { opacity: 0; transform: translateY(-50%) scale(1.7); }
}

/* ---- status line, top left; the heart, top right ---- */
body.swipe .status {
  position: fixed; top: 0.9rem; left: 1.1rem; z-index: 5; margin: 0;
  font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-faint);
  font-variant-numeric: tabular-nums; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
  background: none; border: 0; padding: 0.4rem 0; cursor: pointer;
}
body.swipe .status b { font-weight: 500; color: var(--ink-soft); }
body.swipe .heart {
  position: fixed; top: 0.9rem; right: 1.1rem; z-index: 5; width: 1.5rem; height: 1.5rem;
  fill: none; stroke: var(--ink-faint); stroke-width: 1.6; opacity: 0.8;
  filter: drop-shadow(0 1px 6px rgba(0, 0, 0, 0.8)); transition: opacity 200ms;
}
body.swipe.liked .heart { fill: var(--accent); stroke: var(--accent); opacity: 1; }
body.swipe.quiet .status, body.swipe.quiet .heart, body.swipe.quiet .caption { opacity: 0; }
body.swipe .status, body.swipe .caption { transition: opacity 200ms ease; }

/* ---- the caption: prompt, authors, facts, under a scrim ---- */
body.swipe .caption {
  /* Under the shield (z 3), and no pointer events: a thumb starts most
   * swipes in the bottom third of the screen, which is where this is, and
   * the shield hit-tests a tap against this box to open the words. */
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 2; pointer-events: none;
  padding: 4.5rem 1.1rem 1.5rem; padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
  /* Deep enough to read over a white canvas: a third of the gallery's sketches
   * are light, and a scrim that only works on black is a caption that only
   * works sometimes. */
  background: linear-gradient(to top, rgba(5, 6, 8, 0.94) 0%, rgba(5, 6, 8, 0.8) 55%, rgba(5, 6, 8, 0) 100%);
  display: grid; gap: 0.35rem; cursor: pointer;
}
body.swipe .caption .prompt {
  margin: 0; font-size: 1.2rem; line-height: 1.22; font-weight: 500; text-wrap: balance;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 2px 14px rgba(0, 0, 0, 0.8);
  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
}
body.swipe .caption .prompt .num {
  color: var(--ink-faint); font-weight: 400; margin-right: 0.4em; font-variant-numeric: tabular-nums;
}
body.swipe .caption .revisions {
  margin: 0; font-size: 0.85rem; color: var(--ink-soft);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
body.swipe .caption .revisions b { font-weight: 500; color: var(--ink); }
body.swipe .caption .authors { margin: 0; font-size: 0.82rem; color: var(--ink-soft); }
body.swipe .caption .authors span { color: var(--ink); }
body.swipe .caption .facts {
  margin: 0.15rem 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap;
  gap: 0.25rem 0.9rem; font-size: 0.74rem; letter-spacing: 0.03em; color: var(--ink-soft);
  font-variant-numeric: tabular-nums;
}
body.swipe .caption .facts b { font-weight: 500; color: var(--ink); }
body.swipe .caption .facts .gen b { color: var(--accent); }
body.swipe .caption .facts .touch { color: var(--agent); }
body.swipe .caption .more {
  margin: 0; font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint);
}

/* ---- a toast for the one-line answers ---- */
body.swipe .toast {
  position: fixed; left: 50%; bottom: calc(7.5rem + env(safe-area-inset-bottom)); z-index: 8; transform: translateX(-50%) translateY(6px);
  background: var(--scrim); border: 1px solid var(--line); border-radius: 999px; padding: 0.4rem 0.9rem;
  font-size: 0.82rem; color: var(--ink); opacity: 0; pointer-events: none; white-space: nowrap;
  transition: opacity 160ms, transform 160ms;
}
body.swipe .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }

/* ---- the pill that says the sketch has the touch, and gives it back ---- */
body.swipe .touching-pill {
  position: fixed; left: 50%; bottom: max(1.4rem, env(safe-area-inset-bottom)); z-index: 8; transform: translateX(-50%);
  background: var(--scrim); border: 1px solid var(--agent); border-radius: 999px; padding: 0.5rem 1rem;
  font-size: 0.82rem; color: var(--ink); cursor: pointer; display: none; white-space: nowrap;
}
body.swipe .touching-pill b { color: var(--agent); font-weight: 500; }
body.swipe.touching .touching-pill { display: block; }
body.swipe.touching .caption,
body.swipe.touching .status,
body.swipe.touching .heart { opacity: 0; pointer-events: none; }

/* ---- the sheets: words, judge, settings, sign in ---- */
body.swipe .dim { position: fixed; inset: 0; z-index: 9; background: rgba(5, 6, 8, 0.35); display: none; }
body.swipe .sheet {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; max-height: 86%;
  background: var(--panel); border-top: 1px solid var(--line); border-radius: 1.2rem 1.2rem 0 0;
  padding: 0.6rem 1.1rem 1.4rem; padding-bottom: max(1.4rem, env(safe-area-inset-bottom));
  overflow: hidden auto; scrollbar-width: none; display: none;
  /* The one place a finger may scroll: the sheets are documents, the feed is
   * not. */
  touch-action: pan-y;
  transform: translateY(0); transition: transform 220ms cubic-bezier(0.2, 0.7, 0.2, 1);
}
body.swipe .sheet.open, body.swipe .dim.open { display: block; }
/* Half height, so the stage stays visible above it while a pair is judged. */
body.swipe .sheet.judge { max-height: 58%; }
body.swipe .sheet .grip {
  width: 2.4rem; height: 4px; border-radius: 2px; background: var(--line); margin: 0 auto 0.8rem;
}
body.swipe .sheet .close {
  position: absolute; top: 0.7rem; right: 0.8rem; background: var(--key-ground); border: 0; color: var(--ink);
  width: 1.9rem; height: 1.9rem; border-radius: 50%; cursor: pointer; font-size: 0.95rem;
}
body.swipe .sheet h2 {
  margin: 0 0 0.3rem; font-size: 1.1rem; font-weight: 600; line-height: 1.25;
  padding-right: 2.2rem; text-wrap: balance;
}
body.swipe .sheet h3 {
  margin: 1.1rem 0 0.25rem; font-size: 0.72rem; font-weight: 500; letter-spacing: 0.1em;
  text-transform: uppercase; color: var(--ink-faint);
}
body.swipe .sheet p { margin: 0.25rem 0; font-size: 0.92rem; }
body.swipe .sheet .note { font-size: 0.76rem; color: var(--ink-faint); }
body.swipe .sheet .links {
  display: flex; flex-wrap: wrap; gap: 0.3rem 1.2rem; margin-top: 1rem; font-size: 0.9rem;
}
body.swipe .sheet .pop {
  display: grid; grid-template-columns: 4.6rem 1fr; gap: 0.1rem 0.7rem; align-items: baseline; margin: 0.35rem 0;
}
body.swipe .sheet .pop-label { font-size: 0.74rem; letter-spacing: 0.06em; text-transform: uppercase; }
body.swipe .sheet .human .pop-label { color: var(--accent); }
body.swipe .sheet .agent .pop-label { color: var(--agent); }
/* .score is a chip on the entry page; here it is one line of a two-line block. */
body.swipe .sheet .score {
  background: none; border-radius: 0; padding: 0;
  font-size: 0.98rem; font-variant-numeric: tabular-nums;
}
body.swipe .sheet .quad { grid-column: 2; font-size: 0.82rem; color: var(--ink-soft); }
body.swipe .sheet .fields {
  margin: 0.2rem 0 0; display: grid; grid-template-columns: max-content 1fr; gap: 0.15rem 0.9rem; font-size: 0.85rem;
}
body.swipe .sheet .fields dt { color: var(--ink-faint); margin: 0; }
body.swipe .sheet .fields dd { margin: 0; color: var(--ink); overflow-wrap: anywhere; }
body.swipe .sheet .big {
  display: block; width: 100%; margin-top: 0.9rem; padding: 0.7rem 1rem; border-radius: 8px;
  background: var(--accent); color: var(--ground); border: 0; font-weight: 600; cursor: pointer;
}
body.swipe .sheet .quiet-btn {
  display: block; width: 100%; margin-top: 0.5rem; padding: 0.6rem; background: none; border: 0;
  color: var(--ink-soft); cursor: pointer;
}

/* ---- the judge sheet: two tiles, one of them playing on the stage above ---- */
/* .pair, .question, .ask, .side-label and .side-brief are the compare page's,
 * which this sheet is a phone-sized restatement of; the values differ and the
 * names are kept, because the two are the same two questions. */
body.swipe .pair {
  display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; margin: 0.6rem 0 0;
}
body.swipe .tile {
  text-align: left; background: var(--chip); border: 2px solid transparent; border-radius: 8px;
  padding: 0.6rem 0.7rem; color: var(--ink); cursor: pointer;
}
body.swipe .tile[aria-pressed="true"] { border-color: var(--ink); }
body.swipe .tile .side-label {
  display: flex; justify-content: space-between; font-weight: 600; font-size: 1rem; margin: 0 0 0.2rem;
}
body.swipe .tile .side-label small {
  font-weight: 400; font-size: 0.7rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint);
}
body.swipe .tile[aria-pressed="true"] .side-label small { color: var(--accent); }
/* The brief and nothing else: B's prompt, authors and counts would anchor the
 * answer the sheet is asking for (spec §4.5). */
body.swipe .tile .side-brief {
  margin: 0; font-size: 0.78rem; color: var(--ink-soft); line-height: 1.35;
  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
}
body.swipe .question { border: 0; padding: 0; margin: 0.9rem 0 0; }
body.swipe .question .ask { margin: 0 0 0.35rem; font-size: 0.92rem; font-weight: 400; }
body.swipe .question .choices { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.4rem; margin: 0; }
body.swipe .question .choices button {
  padding: 0.55rem; border-radius: 8px; border: 1px solid var(--line); background: var(--key-ground);
  color: var(--ink); cursor: pointer; font-weight: 500;
}
body.swipe .question .choices button[aria-pressed="true"] {
  background: var(--ink); color: var(--ground); border-color: var(--ink);
}
body.swipe .question .answered {
  margin: 0.3rem 0 0; font-size: 0.76rem; color: var(--ink-faint); min-height: 1em;
}
/* .reveal is a panel on the compare page and a rule under the questions here. */
body.swipe .reveal {
  background: none; border: 0; border-radius: 0; box-shadow: none;
  border-top: 1px solid var(--line); margin: 1rem 0 0; padding: 0.6rem 0 0;
}
body.swipe .reveal[hidden] { display: none; }
body.swipe .reveal .verdict { font-size: 0.85rem; margin: 0.2rem 0; }
body.swipe .reveal .verdict b { color: var(--agent); font-weight: 500; }
body.swipe .reveal .after { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; margin-top: 0.8rem; }
body.swipe .reveal .after button {
  padding: 0.6rem; border-radius: 8px; border: 1px solid var(--line); background: var(--key-ground);
  color: var(--ink); cursor: pointer;
}

/* ---- settings: the order rows, the session, the launch link ---- */
body.swipe .orders { list-style: none; margin: 0.2rem 0 0; padding: 0; display: grid; gap: 0.15rem; }
body.swipe .orders button {
  width: 100%; text-align: left; display: flex; justify-content: space-between; align-items: baseline;
  background: none; border: 0; color: var(--ink-soft); padding: 0.45rem 0.2rem;
  border-bottom: 1px solid var(--line); cursor: pointer;
}
body.swipe .orders button[aria-current="true"] { color: var(--ink); }
body.swipe .orders button[aria-current="true"] .state { color: var(--accent); }
body.swipe .orders .state { font-size: 0.74rem; color: var(--ink-faint); }
body.swipe .session-row { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
body.swipe .session-row button {
  background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0; font-size: 0.9rem;
}
body.swipe .launch {
  font-family: var(--mono); font-size: 0.78rem; color: var(--ink-soft); word-break: break-all;
}

/* ---- the start card: the one gesture ---- */
body.swipe .welcome {
  position: fixed; inset: 0; z-index: 20; display: grid; place-items: center;
  background: var(--ground); padding: 1.5rem;
}
body.swipe .welcome[hidden] { display: none; }
/* .card is the grid's card above, which is a panel with a border and a shadow.
 * Here it is the start card and it is nothing but text on the ground. */
body.swipe .welcome .card {
  background: none; border: 0; border-radius: 0; box-shadow: none; overflow: visible;
  padding: 0; max-width: 20rem; text-align: center;
}
body.swipe .welcome h1 { font-size: 1.2rem; font-weight: 500; margin: 0 0 0.4rem; }
body.swipe .welcome p { color: var(--ink-soft); margin: 0.3rem 0; font-size: 0.95rem; }
body.swipe .welcome .how {
  list-style: none; margin: 1rem 0 0; padding: 0; display: grid; gap: 0.3rem; text-align: left; font-size: 0.9rem;
}
body.swipe .welcome .how li {
  display: grid; grid-template-columns: 5.4rem 1fr; gap: 0.6rem; align-items: baseline;
}
body.swipe .welcome .how b {
  font-weight: 500; color: var(--accent); letter-spacing: 0.04em; font-size: 0.8rem; text-transform: uppercase;
}
body.swipe .welcome .go {
  margin-top: 1.3rem; display: inline-block; padding: 0.65rem 1.5rem; border: 1px solid var(--line);
  border-radius: 8px; background: var(--key-ground); color: var(--ink); cursor: pointer;
}
body.swipe .welcome .go:focus-visible,
body.swipe .welcome .go:hover { border-color: var(--accent); outline: none; }
body.swipe .welcome .note { margin-top: 1rem; font-size: 0.78rem; color: var(--ink-faint); }

@media (prefers-reduced-motion: reduce) {
  body.swipe .stage,
  body.swipe .sheet,
  body.swipe .toast,
  body.swipe .cue,
  body.swipe .caption,
  body.swipe .status { transition: none !important; animation: none !important; }
}

/* The grid's offer, and the only swipe rule that is not scoped to the page.
 * Hidden on a laptop, where the grid is the better page and a swipe is a drag;
 * shown at phone width, where it is the better page. The grid offers it and
 * never imposes it: nothing redirects (spec, "what it never does"). */
.swipe-offer { display: none; }
@media (max-width: 40rem) {
  .swipe-offer { display: block; margin: 0 0 1rem; }
}
