/* HOPE THIS HELPS — structure.
 *
 * This file owns layout and nothing else. Every colour, font, border, bevel and
 * texture comes from a custom property defined in themes.css, because the skin
 * changes completely on every level while the layout never moves at all.
 *
 * If you are adding a rule here and typing a hex code, it belongs in themes.css.
 */

* { box-sizing: border-box; }

html, body { height: 100%; }

body {
  margin: 0;
  background: var(--desk);
  background-image: var(--desk-img);
  color: var(--ink);
  font-family: var(--font);
  font-size: 13px;
  line-height: 1.6;
  letter-spacing: var(--tracking);
  -webkit-font-smoothing: var(--smoothing);
  transition: background-color 120ms linear, color 120ms linear;
}

/* ---------- primitives ---------- */

.btn {
  font: inherit;
  font-family: var(--font-display);
  font-size: 12px;
  letter-spacing: var(--tracking-wide);
  text-transform: var(--btn-transform);
  padding: 4px 14px;
  background: var(--face-alt);
  color: var(--ink);
  border: var(--border-w) var(--border-style) var(--edge);
  border-radius: var(--radius);
  box-shadow: var(--bevel-out);
  cursor: pointer;
}
.btn:hover:not(:disabled) { background: var(--face-hover); }
.btn:active:not(:disabled) { box-shadow: var(--bevel-in); }
.btn:disabled { color: var(--dim); cursor: default; opacity: 0.7; }
.btn-sm { padding: 2px 10px; font-size: 11px; }

:focus-visible { outline: 2px var(--focus-style) var(--focus); outline-offset: 1px; }

.win {
  background: var(--face);
  border: var(--border-w) var(--border-style) var(--edge);
  border-radius: var(--radius);
  box-shadow: var(--bevel-out), var(--win-shadow);
  display: flex;
  flex-direction: column;
}

.titlebar {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: var(--titlebar-pad);
  background: var(--accent);
  color: var(--accent-ink);
  font-family: var(--font-display);
  font-size: var(--titlebar-size);
  font-weight: var(--titlebar-weight);
  letter-spacing: var(--tracking-wide);
  text-transform: var(--titlebar-transform);
  border-bottom: var(--border-w) var(--border-style) var(--edge);
  flex: none;
}
.titlebar > span:nth-child(2) {
  flex: 1;
  text-align: center;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tbtn {
  width: 12px;
  height: 12px;
  flex: none;
  background: var(--tbtn-bg);
  border: var(--border-w) var(--border-style) var(--tbtn-edge);
  box-shadow: var(--bevel-out);
  opacity: var(--tbtn-opacity);
}

.wbody { padding: 18px; display: flex; flex-direction: column; gap: 14px; flex: 1; }

.statusbar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 10px;
  border-top: var(--border-w) var(--border-style) var(--rule);
  background: var(--face-alt);
  font-family: var(--font-display);
  font-size: 11px;
  letter-spacing: var(--tracking-wide);
  color: var(--dim);
  flex: none;
}
.statusbar > .btn:last-child { margin-left: auto; }
.statusbar.right { justify-content: flex-end; }
.statusbar.right > .btn:last-child { margin-left: 0; }
.sb-actions { margin-left: auto; display: flex; gap: 8px; }

/* ---------- chrome modes ----------
 * Three steps, and each one is a beat in the game rather than a style choice:
 *
 *   bare    act I. No menu bar, no Trash, title bar reduced to a caption. The
 *           window is barely a window; the password field is nearly all there is.
 *   window  a real window — title bar, buttons, shadow — but still no menu bar
 *           and no desktop. Used by the level whose puzzle IS that a window is an
 *           object you can pick up and move.
 *   os      act II onward. The operating system arrives all at once, on the same
 *           beat that the answer stops being on screen.
 *
 * The furniture showing up IS the act break, so none of this is cosmetic. */

[data-chrome="bare"] #menubar,
[data-chrome="bare"] #desk-icons,
[data-chrome="bare"] .tbtn { display: none; }

[data-chrome="window"] #menubar,
[data-chrome="window"] #desk-icons { display: none; }
[data-chrome="window"] #stage { padding-top: 6vh; }

[data-chrome="bare"] #stage { padding-top: 8vh; }

[data-chrome="bare"] .titlebar {
  background: transparent;
  color: var(--dim);
  border-bottom-color: var(--rule);
  justify-content: center;
  padding: 10px 8px;
}

[data-chrome="bare"] .wbody { padding: 30px 24px 22px; gap: 20px; }

/* ---------- menu bar ---------- */

#menubar {
  display: flex;
  align-items: stretch;
  background: var(--face);
  border-bottom: var(--border-w) var(--border-style) var(--edge);
  padding: 0 8px;
  flex: none;
  position: relative;
  z-index: 40;
  font-family: var(--font-display);
}
.mb-logo { display: flex; align-items: center; padding: 0 10px 0 2px; color: var(--accent-mark); }
.mb-item {
  font: inherit;
  font-size: 12px;
  letter-spacing: var(--tracking);
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px 12px;
  color: var(--ink);
}
.menu { position: relative; }
.menu.open .mb-item { background: var(--accent); color: var(--accent-ink); }
.mb-drop {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  min-width: 190px;
  background: var(--face);
  border: var(--border-w) var(--border-style) var(--edge);
  box-shadow: var(--bevel-out), var(--win-shadow);
  padding: 3px;
  z-index: 50;
}
.menu.open .mb-drop { display: block; }
.mb-drop button {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  font-size: 12px;
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px 10px;
  color: var(--ink);
}
.mb-drop button:hover:not(:disabled) { background: var(--accent); color: var(--accent-ink); }
.mb-drop button:disabled { color: var(--dim); cursor: default; }
.mb-right,
.mb-meta {
  display: flex;
  align-items: center;
  padding: 0 6px;
  font-size: 11px;
  color: var(--dim);
  letter-spacing: var(--tracking-wide);
  white-space: nowrap;
}
/* who the machine thinks is sitting at it. Pushed right, with the volume and
   level counter after it behind a rule, so the two never read as one string. */
.mb-right { margin-left: auto; }
.mb-right:empty { display: none; }
.mb-right:not(:empty) + .mb-meta {
  border-left: var(--border-w) var(--border-style) var(--rule);
  margin-left: 2px;
}
.mb-meta:first-of-type { margin-left: auto; }

/* A desk has things on it that are nobody's business but the person whose desk
   it is. These are furniture, never puzzle content — see paintDesktop(). */
.desk-icon.desk-personal { opacity: 0.9; }

/* ---------- desktop ---------- */

#desktop { min-height: 100dvh; display: flex; flex-direction: column; }

#stage {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 34px 18px 90px;
}

#mainwin { width: min(660px, 100%); min-height: 400px; position: relative; z-index: 2; }

/* ---------- backdrop ----------
 * A level can paint on the desktop behind the window. The backdrop is sized and
 * centred exactly like the window, so at rest the window covers it completely
 * and the player has to move the window to find out it was ever there. */

#backdrop {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
#backdrop:empty { display: none; }

/* The etching layer sits under the backdrop so a level that paints its own
   desktop always wins. Never interactive — there is nothing here to click. */
#etchings {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}
#etchings:empty { display: none; }

/* things etched into the desktop itself, at a size the screen cannot resolve.
   Not the answer to anything — the reward for pointing a new instrument at
   something nobody told you to point it at. */
.lv-etched {
  position: absolute;
  inset: 0;
  color: var(--desk-ink);
  opacity: 0.75;
}
/* #backdrop resolves against the viewport rather than the stage, so these are
   placed against the whole screen — which means keeping clear of the furniture
   that paints over it: the menu bar (z 40) along the top and the icon column
   (z 30) down the right. Etchings under either are invisible even magnified,
   which is not mysterious, just wrong. */
.lv-etched .et { position: absolute; }
.lv-etched .et .mtext { color: var(--desk-ink); }
.lv-etched .tl { top: 52px; left: 26px; }
.lv-etched .tr { top: 52px; right: 156px; }
.lv-etched .bl { bottom: 28px; left: 26px; }
.lv-etched .br { bottom: 28px; right: 156px; }

.lv-behind {
  width: min(660px, 100%);
  min-height: 400px;
  display: flex;
  align-items: center;
  justify-content: space-evenly;
  padding: 0 6%;
  font-family: var(--font-display);
  font-size: clamp(34px, 6vw, 58px);
  letter-spacing: 0.1em;
  color: var(--desk-ink);
  opacity: 0.85;
}

/* a window you can pick up */
[data-drag="on"] .titlebar,
[data-drag="on"] #level { cursor: grab; touch-action: none; }
[data-drag="on"].dragging .titlebar,
[data-drag="on"].dragging #level { cursor: grabbing; }
[data-drag="on"] #mainwin { will-change: transform; }

/* level 9 keeps the window at full height with nothing in it */
.lv-empty { min-height: 190px; }

/* icons live in a right-hand column, the way a desktop actually arranges them.
   The Trash stays up with the others rather than pinned to the bottom, because
   the bottom-right corner is where ARP sits and he'd cover it. */
#desk-icons {
  position: fixed;
  top: 46px;
  bottom: 20px;
  right: 16px;
  z-index: 30;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.desk-icon {
  background: none;
  border: none;
  cursor: pointer;
  font: inherit;
  font-family: var(--font-display);
  font-size: 11px;
  letter-spacing: var(--tracking);
  color: var(--desk-ink);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 5px;
  padding: 5px 4px;
  width: 84px;
  text-align: center;
  line-height: 1.25;
}
.desk-icon:hover .ico { filter: brightness(1.08); }
.desk-icon:active .ico { box-shadow: var(--bevel-in); }

.ico {
  background: var(--face);
  border: var(--border-w) var(--border-style) var(--edge);
  box-shadow: var(--bevel-out);
  position: relative;
  display: block;
}

/* hard disk */
.ico-disk { width: 38px; height: 27px; }
.ico-disk::before {
  content: "";
  position: absolute;
  left: 4px;
  right: 12px;
  top: 5px;
  height: 3px;
  background: var(--dim);
}
.ico-disk::after {
  content: "";
  position: absolute;
  right: 4px;
  bottom: 4px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent-mark);
}

/* folder, with its tab */
.ico-folder { width: 38px; height: 27px; margin-top: 5px; }
.ico-folder::before {
  content: "";
  position: absolute;
  top: -6px;
  left: -1px;
  width: 16px;
  height: 6px;
  background: var(--face);
  border: var(--border-w) var(--border-style) var(--edge);
  border-bottom: none;
}

/* trash, tapered, with a lid */
.ico-trash { width: 28px; height: 30px; clip-path: polygon(0 0, 100% 0, 88% 100%, 12% 100%); }
.ico-trash::before {
  content: "";
  position: absolute;
  inset: 6px 5px auto 5px;
  height: 2px;
  background: var(--dim);
  box-shadow: 0 6px 0 var(--dim), 0 12px 0 var(--dim);
}
.desk-icon.full .ico-trash { background: var(--accent); }

/* files dragged back out of the Trash. The layer is inert; only the icons on it
   take the pointer, so dropping a file never blocks the window underneath. */
#desk-files {
  position: fixed;
  inset: 0;
  z-index: 30;
  pointer-events: none;
}
.desk-file {
  position: absolute;
  pointer-events: auto;
  max-width: 96px;
  word-break: break-all;
}

/* what follows the pointer between the Trash and the desktop */
.drag-ghost {
  position: fixed;
  z-index: 140;
  transform: translate(-42px, -20px);
  pointer-events: none;
  opacity: 0.75;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 5px;
  width: 84px;
  text-align: center;
  font-family: var(--font-display);
  font-size: 11px;
  letter-spacing: var(--tracking);
  color: var(--desk-ink);
  line-height: 1.25;
}

/* keep the window clear of the icon column */
[data-chrome="os"] #stage { padding-right: 118px; }

@media (max-width: 760px) {
  #desk-icons {
    flex-direction: row;
    top: auto;
    bottom: 6px;
    left: 6px;
    right: 6px;
    justify-content: flex-end;
    gap: 4px;
  }
  #trash { margin-top: 0; }
  .desk-icon { width: 72px; font-size: 10px; }
  [data-chrome="os"] #stage { padding-right: 10px; padding-bottom: 108px; }
}

/* ---------- entry ---------- */

.inputrow { display: flex; align-items: center; gap: 9px; margin-top: auto; flex-wrap: wrap; }
.inputrow label {
  font-family: var(--font-display);
  font-size: 12px;
  letter-spacing: var(--tracking-wide);
  flex: none;
}
.sunken {
  flex: 1 1 150px;
  background: var(--field);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  padding: 4px 8px;
  display: flex;
}
.sunken input {
  font: inherit;
  font-family: var(--font-input);
  font-size: 14px;
  letter-spacing: 0.14em;
  width: 100%;
  background: none;
  border: none;
  color: var(--field-ink);
}
.sunken input:focus { outline: none; }
.sunken:focus-within { outline: 2px var(--focus-style) var(--focus); outline-offset: 1px; }

.resp { min-height: 1.5em; font-size: 12px; color: var(--alert); }
.resp.ok { color: var(--ok); }
.resp.near { color: var(--warn); }

/* ---------- level content ---------- */

#level { display: flex; flex-direction: column; gap: 12px; }
#level p { margin: 0; }
.lv-lead { font-weight: 700; letter-spacing: var(--tracking-wide); }
.lv-dim { color: var(--dim); font-size: 12px; }

.lv-files { border-collapse: collapse; width: 100%; font-size: 12px; }
.lv-files th, .lv-files td { text-align: left; padding: 3px 10px; }
.lv-files thead th {
  background: var(--face-alt);
  border: var(--border-w) var(--border-style) var(--edge);
  box-shadow: var(--bevel-out);
  cursor: pointer;
  font-family: var(--font-display);
  letter-spacing: var(--tracking-wide);
  font-weight: 400;
  color: var(--dim);
  user-select: none;
}
.lv-files thead th.lv-sorted { color: var(--ink); }
.lv-files thead th.lv-sorted::after { content: " ▾"; }
.lv-files tbody tr:nth-child(odd) { background: var(--stripe); }
.lv-files tbody td {
  border-bottom: 1px solid var(--rule);
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

.lv-mojibake {
  font-family: var(--font-mono);
  font-size: 14px;
  line-height: 1.8;
  margin: 0;
  background: var(--field);
  color: var(--field-ink);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  padding: 12px 14px;
  overflow-x: auto;
}

/* A transcript taller than the box it is shown in. The window is the puzzle: it
   claims the page ends where it stops drawing. Height is pinned in `em` so it
   always cuts after the third line whatever the theme does to the font, and it
   is focusable so the keyboard can scroll it too — fairness rule 4. */
.lv-margin {
  font-family: var(--font-mono);
  font-size: 14px;
  line-height: 1.8;
  margin: 0;
  background: var(--field);
  color: var(--field-ink);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  padding: 12px 14px;
  max-height: 6.4em;
  overflow: auto;
}

.lv-log {
  background: var(--field);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  padding: 8px 12px;
  font-family: var(--font-mono);
  font-size: 12px;
  min-height: 3em;
  color: var(--alert);
  white-space: pre-line;
}
.lv-log:empty::before { content: "No faults logged."; color: var(--dim); }

/* The request that produced the page. Level 28 is the only level in the game
   that asks the address bar a question, and nothing before it ever mentions a
   URL — so the request has to be legible on screen before "amend the request"
   can mean anything. Showing it is the one solved instance the encoding rule
   asks for: the player edits a string they can see rather than inventing
   query-string syntax they were never taught. */
.lv-request {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--dim);
  letter-spacing: 0.04em;
  border-bottom: var(--border-w) var(--border-style) var(--rule);
  padding-bottom: 7px;
  margin-bottom: 14px;
}
.lv-request b { color: var(--ink); font-weight: inherit; }

/* ── act I puzzle furniture ──────────────────────────────────────────────
   Four things on screen, four digits, left to right. Every one of these is
   read in a single glance and resolved in a single step — that grammar is the
   whole of act I, so these components stay deliberately plain. */

.lv-count, .lv-shapes, .lv-clocks, .lv-roman,
.lv-lamps, .lv-negative, .lv-dominoes, .lv-abacus {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 18px;
  flex-wrap: wrap;
  padding: 30px 12px;
}

/* counted dots, in boxes so an empty box still reads as a box */
.lv-count .grp {
  width: 104px;
  min-height: 104px;
  padding: 9px;
  display: flex;
  flex-wrap: wrap;
  align-content: center;
  justify-content: center;
  gap: 7px;
  border: var(--border-w) var(--border-style) var(--edge);
  background: var(--field);
}
.lv-count i {
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background: var(--ink);
  display: block;
}

/* dominoes — one tile is one digit, and it has two halves */
.lv-dominoes .dom {
  width: 88px;
  display: flex;
  flex-direction: column;
  border: 2px solid var(--ink);
  background: var(--field);
}
.lv-dominoes .dom b {
  flex: 1;
  min-height: 68px;
  display: flex;
  flex-wrap: wrap;
  align-content: center;
  justify-content: center;
  gap: 5px;
  padding: 8px;
  max-width: 100%;
}
.lv-dominoes .dom b:first-child { border-bottom: 2px solid var(--ink); }
.lv-dominoes .dom i {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--ink);
  display: block;
}

/* polygons — count the sides, not the shapes */
.lv-shapes .shp { width: 84px; height: 84px; background: var(--ink); display: block; }
.lv-shapes .tri  { clip-path: polygon(50% 2%, 98% 96%, 2% 96%); }
.lv-shapes .sq   { clip-path: polygon(4% 4%, 96% 4%, 96% 96%, 4% 96%); }
.lv-shapes .pent { clip-path: polygon(50% 2%, 98% 38%, 80% 96%, 20% 96%, 2% 38%); }
.lv-shapes .hex  { clip-path: polygon(26% 3%, 74% 3%, 98% 50%, 74% 97%, 26% 97%, 2% 50%); }

/* dials — only one hand is saying anything */
.lv-clocks .clk {
  width: 88px;
  height: 88px;
  border-radius: 50%;
  border: 2px solid var(--ink);
  background:
    repeating-conic-gradient(var(--ink) 0deg 1.4deg, transparent 1.4deg 30deg);
  position: relative;
  display: block;
}
.lv-clocks .clk::before,
.lv-clocks .clk::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 50%;
  width: 3px;
  background: var(--ink);
  transform-origin: 50% 100%;
}
.lv-clocks .clk::before { height: 27px; transform: translateX(-50%) rotate(var(--h)); }  /* hour */
.lv-clocks .clk::after  { height: 36px; width: 2px; transform: translateX(-50%); }        /* minute, always 12 */

/* numerals */
.lv-roman span {
  min-width: 92px;
  padding: 16px 12px;
  text-align: center;
  font-family: var(--font-display);
  font-size: 38px;
  letter-spacing: 0.1em;
  border: var(--border-w) var(--border-style) var(--edge);
  background: var(--field);
  color: var(--field-ink);
}

/* lamps — two states, two digits */
.lv-lamps .lamp {
  width: 58px;
  height: 58px;
  border-radius: 50%;
  border: 2px solid var(--ink);
  background: var(--seg-off);
  display: block;
}
.lv-lamps .lamp.on { background: var(--seg-on); box-shadow: var(--seg-glow); }

/* negative space — the ink is not the message. Currently unused; parked in
   PUZZLE-SEEDS.md as a future act I side-level. Not dead code. */
.lv-negative .blk {
  width: 98px;
  height: 98px;
  background: var(--ink);
  position: relative;
  display: block;
}
.lv-negative .blk u {
  position: absolute;
  width: 23px;
  height: 23px;
  border-radius: 50%;
  background: var(--face);
}
.lv-negative .h1 { top: 17px; left: 17px; }
.lv-negative .h2 { bottom: 17px; right: 17px; }
.lv-negative .h3 { top: 17px; right: 17px; }
.lv-negative .h4 { bottom: 17px; left: 17px; }

/* ── batch 2 furniture ──────────────────────────────────────────────────── */

/* digits emerging from a field of dots — size AND brightness, never colour
   alone, so it stays readable without colour vision */
/* The gap is the difficulty dial. Loose dots stop reading as a glyph and start
   reading as a field, which is the whole point of the level — clamped so the
   grid still fits a narrow window. */
.lv-dots { padding: 20px 8px; display: flex; flex-direction: column; align-items: center; gap: clamp(5px, 1.3vw, 10px); }
.lv-dots .drow { display: flex; gap: clamp(5px, 1.3vw, 10px); align-items: center; }
/* Dots that only answer while you are on them.
 *
 * Each dot sits in a fixed square cell with the mark drawn small and centred,
 * so the hit area is the cell rather than the mark — sweeping the field has to
 * be a sweep, not a hunt for a three-pixel target. `--s` is the size the dot
 * reaches when touched; at rest every dot in the field is identical.
 *
 * Only `.sig` grows. The noise dots lift very slightly so that the field feels
 * alive under the pointer and a player cannot tell "this dot did nothing" from
 * "this dot is not interactive" without reading what the big ones spell. */
.lv-dots i {
  width: 14px;
  height: 14px;
  display: grid;
  place-items: center;
  flex: none;
  cursor: crosshair;
}
.lv-dots i::before {
  content: "";
  width: 3.4px;
  height: 3.4px;
  border-radius: 50%;
  background: var(--ink);
  opacity: var(--o);
  transition: width 90ms ease-out, height 90ms ease-out, opacity 90ms ease-out;
}
.lv-dots i:hover::before { width: 5px; height: 5px; }
.lv-dots i.sig:hover::before {
  width: var(--s);
  height: var(--s);
  opacity: 1;
}

/* Touch has no hover, so on a phone the field would be inert. A press opens the
   dot instead. Deliberately NOT made focusable: there are a hundred and forty
   dots, tabbing through them would be miserable, and giving `tabindex` to only
   the signal ones would hand the answer to anyone reading the DOM. */
.lv-dots i.sig:active::before {
  width: var(--s);
  height: var(--s);
  opacity: 1;
}

/* the same digits as latched cells on a dead panel */
.lv-pixels {
  padding: 18px;
  background: var(--seg-bg);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  width: max-content;
  max-width: 100%;
  margin: 0 auto;
  overflow-x: auto;
}
.lv-pixels .prow { display: flex; gap: 3px; }
.lv-pixels i { width: 7px; height: 7px; background: var(--seg-off); display: block; flex: none; }
.lv-pixels i.stuck { background: var(--seg-on); box-shadow: var(--seg-glow); }

/* The panel refreshes one column at a time, and a latched cell is only visible
   while its own column is being refreshed. At rest it is indistinguishable from
   a dead cell — that is the whole puzzle, so it must go back to `--seg-off`
   rather than to transparent, or the gaps in the grid would give the pattern
   away without the player watching anything.

   Tunables: --px-step is how long each column holds, --px-cycle is the whole
   pass plus the dark pause before it repeats. If you change either, the 2.9%
   below is --px-step / --px-cycle expressed as a percentage — that fraction is
   the only thing keeping exactly one column lit at a time. */
.lv-pixels.sweeping {
  --px-step: 0.25s;
  --px-cycle: 8.5s;
}
.lv-pixels.sweeping i.stuck {
  background: var(--seg-off);
  box-shadow: 0 0 0 0 transparent;
  animation: px-sweep var(--px-cycle) steps(1, end) infinite;
  animation-delay: calc(var(--c) * var(--px-step));
}
@keyframes px-sweep {
  0%      { background: var(--seg-off); box-shadow: 0 0 0 0 transparent; }
  0.01%   { background: var(--seg-on);  box-shadow: var(--seg-glow); }
  2.9%    { background: var(--seg-on);  box-shadow: var(--seg-glow); }
  2.91%   { background: var(--seg-off); box-shadow: 0 0 0 0 transparent; }
  100%    { background: var(--seg-off); box-shadow: 0 0 0 0 transparent; }
}

/* A player who has turned animation off cannot watch a sweep, and an unsolvable
   level is worse than an easy one — so for them the panel simply stays latched,
   which is what this level was before the sweep. */
@media (prefers-reduced-motion: reduce) {
  .lv-pixels.sweeping i.stuck {
    animation: none;
    background: var(--seg-on);
    box-shadow: var(--seg-glow);
  }
}

/* The archive's copy of a reply. Level 41 is the first level that needs the
   developer tools, so it cannot also be the level that expects you to already
   know what a response header is — this is the worked example. It shows two
   ordinary headers and names the withheld one, which turns the puzzle from
   "guess where I am hiding" into "go and retrieve a thing you can name". */
.lv-reply {
  background: var(--field);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  font-family: var(--font-mono);
  font-size: 12px;
  padding: 10px 14px;
  margin: 14px 0;
  text-align: left;
}
.lv-reply-head {
  color: var(--dim);
  margin: 0 0 8px;
  padding-bottom: 8px;
  border-bottom: var(--border-w) var(--border-style) var(--rule);
  letter-spacing: 0.08em;
}
.lv-reply dl { margin: 0; }
.lv-reply dl > div { display: flex; gap: 10px; line-height: 1.9; }
.lv-reply dt { color: var(--dim); min-width: 15ch; }
.lv-reply dd { margin: 0; color: var(--ink); }
.lv-reply dd.withheld { color: var(--dim); font-style: italic; }

/* a lamp that flashes in groups */
.lv-blink { display: flex; justify-content: center; padding: 26px 0; }
.lv-blink .lamp {
  width: 58px;
  height: 58px;
  border-radius: 50%;
  border: 2px solid var(--ink);
  background: var(--seg-off);
  display: block;
  transition: background 60ms linear;
}
.lv-blink .lamp.on { background: var(--seg-on); box-shadow: var(--seg-glow); }

/* an impression and its proof */
.lv-mirror { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 18px 0; }
.lv-mirror .face,
.lv-mirror .foil span {
  font-family: var(--font-display);
  font-size: 52px;
  letter-spacing: 0.16em;
}
.lv-mirror .face { transform: scaleX(-1); }
.lv-mirror .foil {
  transform: scaleX(-1);
  padding: 8px 22px;
  background: var(--face-alt);
  border: var(--border-w) var(--border-style) var(--rule);
  opacity: 0.85;
}

/* two sheets, one loose */
.lv-overlay {
  position: relative;
  height: 190px;
  width: 100%;
  overflow: hidden;
  border: var(--border-w) dashed var(--rule);
}
.lv-overlay .sheet {
  position: absolute;
  width: 250px;
  height: 96px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-display);
  font-size: 54px;
  letter-spacing: 0.18em;
  overflow: hidden;
}
.lv-overlay .sheet span { display: block; }
/* each sheet shows only half of the same glyphs */
.lv-overlay .fixed { left: 22px; top: 20px; }
.lv-overlay .fixed span { clip-path: inset(0 0 50% 0); }
/* the loose sheet starts parked over the fixed sheet's second half — the 68 —
   so the code can't be read until the sheet is actually moved */
.lv-overlay .loose {
  left: 138px;
  top: 30px;
  cursor: grab;
  background: var(--face);
  border: var(--border-w) var(--border-style) var(--rule);
}
.lv-overlay .loose span { clip-path: inset(50% 0 0 0); }
.lv-overlay .loose.held { cursor: grabbing; }

/* a slide that needs magnifying */
.lv-slide {
  position: relative;
  height: 190px;
  background: var(--field);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
}
.lv-slide .micro { display: flex; flex-direction: column; }

/* Zoom must not be a substitute for the instrument.
 *
 * Playtest: the slide was readable by hitting Cmd-+. That is fatal to the
 * level — its whole argument is that the answer is below the resolution of the
 * screen and the fix is a tool, not leaning in. Browser zoom re-rasterises
 * everything at the new scale, so real geometry alone was never going to hold.
 *
 * A blur in CSS pixels does hold, because it scales with the zoom: at 200% the
 * content is twice the size and so is the blur, and the ratio a reader needs is
 * exactly the same as it was. There is no zoom level at which the digits
 * resolve.
 *
 * The lens then strips it. `#lens` renders a clone of the desktop at 12x, so
 * the rule below it is the one that decides what the glass shows — and what it
 * shows is the same geometry without the frosting. That is the whole trick:
 * the slide is not hidden, it is out of focus, and the magnifier is what
 * focuses it. */
.lv-slide .micro,
.lv-slide .micro-note,
.lv-etched .et {
  filter: blur(1.6px);
}
#lens .lv-slide .micro,
#lens .lv-slide .micro-note,
#lens .lv-etched .et {
  filter: none;
}
.lv-slide .micro-note { display: block; }

/* Print below the resolution of the screen. Two things are going on, and both
   are load-bearing:

   1. The dots are laid out at a whole pixel each, because sub-pixel boxes do
      not survive. A 0.4px dot gets snapped and merged at layout time and the
      digit comes out of the magnifier as a solid black bar — tried it, it
      destroys the shapes before the glass ever sees them.
   2. The whole block is then scaled DOWN by transform, which is paint rather
      than layout. On screen the code is a ten-pixel grey smudge that no amount
      of leaning in resolves.

   The magnifier works because transforms compose: the lens scales the clone by
   12, this scales it by 0.4, and the browser re-rasterises the result at the
   product — so every dot lands as a clean five-device-pixel square. */
.micro, .mtext { transform: scale(0.4); transform-origin: center; }
.mrow { display: flex; }
.mrow i { width: 1px; height: 1px; display: block; flex: none; background: transparent; }
.mrow i.ink { background: var(--field-ink); }
.mtext {
  display: inline-block;
  font-family: var(--font-display);
  font-size: 2.8px;
  letter-spacing: 0.04em;
  white-space: nowrap;
  color: var(--field-ink);
}

/* a readout that disagrees with the keyboard */
.lv-readout {
  font-family: var(--font-mono);
  font-size: 40px;
  letter-spacing: 0.3em;
  text-align: center;
  padding: 18px 10px;
  background: var(--field);
  color: var(--field-ink);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
}

/* plates */
.lv-plates { display: flex; justify-content: center; gap: 18px; flex-wrap: wrap; padding: 18px 0; }
.lv-plates img {
  width: 92px;
  height: 92px;
  border: var(--border-w) var(--border-style) var(--edge);
  background: var(--face-alt);
}
.lv-plates.one img { width: 120px; height: 120px; }

/* a docket that does not add up */
.lv-docket td:last-child,
.lv-docket th:last-child { text-align: right; }
.lv-docket tr.total td { border-top: 2px solid var(--edge); font-weight: 700; }

/* ── act II furniture ───────────────────────────────────────────────────── */

/* text that is present but not printing — reveal it by selecting */
/* Text printed in the colour of the paper.
 *
 * It used to be `color: var(--face)` — the paper's own token — and that is the
 * bug a playtester caught on level 17: during the tear between levels the new
 * skin's tokens are not applied yet, so for a few frames the "invisible" code
 * rendered in the inherited colour and sat there in plain sight. The one level
 * whose whole puzzle is that you have to select the text handed it over for
 * free, every single time, before the puzzle started.
 *
 * `transparent` cannot flash, because it does not depend on a token that has to
 * arrive. Selection is what reveals it, so ::selection names an explicit colour
 * rather than inheriting one — a transparent glyph stays transparent under a
 * selection that only sets a background. */
.lv-invisible { color: transparent; user-select: text; }
.lv-invisible::selection { color: var(--ink); background: var(--seg-on); }
.lv-invisible::-moz-selection { color: var(--ink); background: var(--seg-on); }

/* a records slip whose operator field was never cleared. The slip is printed
   furniture; the only thing that matters is that one field looks blank. */
.lv-record { display: flex; flex-direction: column; gap: 14px; }
.rec-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 12px;
  padding-bottom: 6px;
  border-bottom: 3px solid var(--edge);
  font-family: var(--font-display);
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
}
.rec-stamp { border: 2px solid var(--edge); padding: 2px 8px; }
.rec-fields {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: 4px 26px;
  margin: 0;
  font-size: 12px;
}
.rec-fields > div {
  display: flex;
  gap: 10px;
  align-items: baseline;
  padding-bottom: 3px;
  border-bottom: 1px solid var(--rule);
}
.rec-fields dt {
  min-width: 72px;
  color: var(--dim);
  letter-spacing: var(--tracking-wide);
}
.rec-fields dd { margin: 0; font-weight: 700; }
/* a name struck out with a marker, which is a thing you can see was removed */
.rec-redact { display: inline-block; width: 92px; height: 11px; background: var(--ink); }
.rec-line { margin: 0; }

/* the field itself. Deliberately oversized: an empty box this big is the
   loudest thing on the slip, and the code fills it once it prints. */
.rec-box { padding: 9px 14px 16px; border: 3px solid var(--edge); }
.rec-label {
  display: block;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
  color: var(--dim);
}
.rec-code {
  margin: 0;
  text-align: center;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(66px, 17vw, 128px);
  line-height: 1.06;
  letter-spacing: 0.08em;
}
.rec-foot {
  margin: 0;
  font-size: 11px;
  letter-spacing: var(--tracking-wide);
  color: var(--dim);
}

/* the desktop it was filed on — repeated type running out from behind the
   window. Uniform on purpose: nothing here is countable. */
.lv-deskprint {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 2vh;
  overflow: hidden;
  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  color: var(--desk-ink);
  opacity: 0.1;
  user-select: none;
}
.lv-deskprint span {
  display: block;
  text-align: center;
  white-space: nowrap;
  font-size: clamp(44px, 11vw, 148px);
  line-height: 0.86;
}
.lv-deskprint .rule { display: block; height: 7px; background: var(--desk-ink); }

/* files that only give up their details when you point at them */
.lv-details { display: flex; gap: 10px; flex-wrap: wrap; padding: 14px 0; }
.fitem {
  position: relative;
  font: inherit;
  font-family: var(--font-display);
  font-size: 11px;
  color: var(--ink);
  background: none;
  border: none;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  width: 84px;
  padding: 8px 4px;
}
.fitem .tip {
  position: absolute;
  bottom: -6px;
  left: 50%;
  transform: translateX(-50%);
  white-space: nowrap;
  background: var(--field);
  color: var(--field-ink);
  border: var(--border-w) var(--border-style) var(--edge);
  box-shadow: var(--win-shadow);
  padding: 3px 8px;
  font-size: 11px;
  letter-spacing: var(--tracking-wide);
  opacity: 0;
  pointer-events: none;
}
.fitem:hover .tip,
.fitem:focus-visible .tip,
.fitem:active .tip { opacity: 1; }

/* A record that has been taken off the spool. The icon stays where it was,
   greyed, rather than vanishing the way a real desktop would — the answer is
   read in spool order, so the empty slot is doing fairness work, not decoration. */
.fitem.taken { opacity: 0.3; cursor: default; }
.fitem.taken .ico { box-shadow: var(--bevel-in); }

/* What the machine says when you try to read a record without taking it off. */
.lv-note {
  display: block;
  min-height: 1.6em;
  font-size: 12px;
  color: var(--alert);
  letter-spacing: var(--tracking-wide);
}

/* One number, set as large as the document will carry. The file dialog is the
   whole page here — there is nothing else in the record. */
.doc-glyph {
  font-family: var(--font-display);
  font-size: clamp(96px, 24vw, 170px);
  line-height: 1;
  text-align: center;
  padding: 12px 0 18px;
  letter-spacing: 0.04em;
}

/* a document icon, folded corner and all */
.ico-doc { width: 28px; height: 34px; }
.ico-doc::before {
  content: "";
  position: absolute;
  top: -1px;
  right: -1px;
  width: 11px;
  height: 11px;
  background: var(--desk);
  border-left: var(--border-w) var(--border-style) var(--edge);
  border-bottom: var(--border-w) var(--border-style) var(--edge);
}
.ico-doc::after {
  content: "";
  position: absolute;
  left: 5px;
  right: 5px;
  bottom: 6px;
  height: 2px;
  background: var(--dim);
  box-shadow: 0 -5px 0 var(--dim), 0 -10px 0 var(--dim);
}

/* a log longer than its window */
.lv-scroll {
  height: 150px;
  overflow-y: auto;
  margin: 0;
  padding: 10px 14px;
  background: var(--field);
  color: var(--field-ink);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 1.7;
  white-space: pre;
}
.lv-scroll b { color: var(--alert); font-weight: 700; }

/* a log that has been blacked out entirely */
.lv-redacted {
  height: 150px;
  background: #000;
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
}

/* the level that asks where it is. Two ruled blanks, big enough that they read
   as a form waiting to be filled in rather than as a sentence with words
   missing — the shape of the question is the whole clue. */
.lv-position {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 40px 8px 34px;
}
.pos-line {
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: 0.38em;
  font-family: var(--font-display);
  font-size: clamp(26px, 6.4vw, 46px);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  white-space: nowrap;
}
.pos-blank {
  display: inline-block;
  width: 1.9em;
  border-bottom: 3px solid var(--ink);
}

/* a field that stores more than it prints */
.lv-field {
  height: 40px;
  background: var(--field);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
}

/* an index of records that print less than they store. The header admits it. */
.lv-index { width: 100%; border-collapse: collapse; margin: 6px 0 4px; }
.lv-index th {
  padding: 4px 12px;
  text-align: left;
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
  color: var(--dim);
  border-bottom: var(--border-w) var(--border-style) var(--rule);
}
.lv-index th:last-child { text-align: right; }
.lv-index td { padding: 7px 12px; border-bottom: 1px solid var(--rule); }
.lv-index tbody tr:hover { background: var(--stripe); }
.lv-index .rid { font-weight: 700; letter-spacing: 0.08em; }
.lv-index .val {
  text-align: right;
  font-family: var(--font-display);
  font-size: 22px;
  line-height: 1;
}
.lv-index .blank { color: var(--dim); }

/* an object you have picked, which is the thing Get Info reports on. Generic:
   any level can hang [data-info] on anything and get this for free. */
#level [data-info] { cursor: default; }
#level [data-info].selected,
#level [data-info].selected td {
  background: var(--accent);
  color: var(--accent-ink);
}
#level [data-info].selected .blank { color: inherit; }

/* abacus — currently unused. Parked in PUZZLE-SEEDS.md; not dead code. */
.lv-abacus {
  background: var(--field);
  border: 3px solid var(--ink);
  padding: 16px 20px;
  gap: 26px;
}
.lv-abacus .rod {
  width: 34px;
  display: flex;
  flex-direction: column;
  align-items: center;
  position: relative;
}
.lv-abacus .rod::before {
  content: "";
  position: absolute;
  top: 4px;
  bottom: 4px;
  width: 3px;
  background: var(--dim);
}
.lv-abacus .heaven { height: 58px; }
.lv-abacus .earth { height: 132px; }
.lv-abacus .heaven,
.lv-abacus .earth {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  position: relative;
  z-index: 1;
}
.lv-abacus .gap { flex: 1; }
.lv-abacus b {
  width: 30px;
  height: 21px;
  flex: none;
  background: var(--ink);
  clip-path: polygon(0 50%, 26% 0, 74% 0, 100% 50%, 74% 100%, 26% 100%);
}
.lv-abacus .bar {
  width: 100%;
  height: 5px;
  background: var(--ink);
  flex: none;
  position: relative;
  z-index: 2;
}
/* the worked example. Without this the level is a soroban-literacy test rather
   than a puzzle — nothing else on screen lets you confirm the encoding. */
.lv-abacus .key { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.lv-abacus .key em {
  font-style: normal;
  font-family: var(--font-display);
  font-size: 17px;
  letter-spacing: var(--tracking-wide);
  color: var(--dim);
}
.lv-abacus .rule {
  width: 3px;
  align-self: stretch;
  background: var(--rule);
  margin: 0 4px 26px;
}

/* ── the layer batch (levels 35–47) ────────────────────────────────────── */

/* The filed copy. Invisible on screen and the only thing left on paper — see
   the print stylesheet at the foot of this file. */
.lv-print { display: none; }

/* The mark the printer puts on every other level. Same deal — screen never sees
   it, paper always does. */
#printmark { display: none; }

/* a form that says, on its face, that it is the wrong copy */
.lv-form { display: flex; flex-direction: column; gap: 12px; }
.fm-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 12px;
  padding-bottom: 5px;
  border-bottom: var(--border-w) var(--border-style) var(--edge);
  font-family: var(--font-display);
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
}
.fm-stamp {
  padding: 2px 8px;
  border: 2px solid var(--alert);
  color: var(--alert);
  font-size: 10px;
  transform: rotate(-1.5deg);
}
/* the code's own row, blanked — the field exists, the value is elsewhere */
.fm-withheld {
  display: inline-block;
  width: 62px;
  height: 10px;
  margin-right: 8px;
  background: repeating-linear-gradient(
    90deg, var(--rule) 0 3px, transparent 3px 6px);
  vertical-align: -1px;
}
.fm-release {
  margin: 0;
  font-size: 11px;
  line-height: 1.7;
  color: var(--dim);
  border-top: 1px solid var(--rule);
  padding-top: 8px;
}

/* A printed step wedge: a labelled 0–9 calibration strip, then unmarked
   patches to match against it. Density is carried by an inner layer's opacity
   rather than by the box, so a patch at zero is still a bordered, visible
   patch — the same "an empty box still counts" beat as level 2. */
.lv-wedge {
  display: flex;
  justify-content: center;
  gap: 6px;
  padding: 8px 0 4px;
  flex-wrap: wrap;
}
.lv-wedge .stp { display: flex; flex-direction: column; align-items: center; gap: 5px; }
.lv-wedge .stp i {
  position: relative;
  display: block;
  width: 30px;
  height: 30px;
  border: 1px solid var(--edge);
  background: var(--face);
}
.lv-wedge .stp i::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--ink);
  opacity: var(--k);
}
.lv-wedge .stp em {
  font-style: normal;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--dim);
}
.lv-wedge.patches { padding-top: 4px; gap: 16px; }
.lv-wedge.patches .stp i { width: 54px; height: 54px; }

/* Display order is a stylesheet's decision. Document order is not, and copying
   takes the document. */
/* Display order is a CSS decision; document order is not, and copying takes the
   document. `user-select: all` is what makes that reachable: one click selects
   the whole row as a block. Dragging across flex-reordered children selects
   erratically — the range is in document order while the highlight follows the
   visual one — and select-all takes the entire page furniture with it. Either
   way the player pastes a mess and the level reads as broken. */
.lv-order {
  display: flex;
  justify-content: center;
  gap: 18px;
  padding: 22px 0;
  user-select: all;
  -webkit-user-select: all;
  cursor: pointer;
}
.lv-order span {
  order: var(--o);
  font-family: var(--font-display);
  font-size: 46px;
  letter-spacing: var(--tracking-wide);
}

/* The caption labels what you are looking at, not what you want. */
.lv-caption {
  text-align: center;
  letter-spacing: var(--tracking-wide);
  margin: 0;
}

/* The two keystrokes, drawn as keys. The level is about the clipboard, so the
   instruction is the keys themselves rather than a sentence describing them. */
.lv-keys {
  display: flex;
  justify-content: center;
  gap: 20px;
  padding: 10px 0 4px;
}
.lv-keys span {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 58px;
  height: 58px;
  font-family: var(--font-display);
  font-size: 32px;
  letter-spacing: 0;
  color: var(--ink);
  background: var(--face-alt);
  border: var(--border-w) var(--border-style) var(--edge);
  border-radius: var(--radius);
  box-shadow: var(--bevel-out);
}

/* A button that genuinely does nothing when pressed, and says so while held.
   The data is in the hover state, which is why it is wired for pointer,
   keyboard focus and touch alike — fairness rule 4. */
.lv-nopress {
  display: flex;
  justify-content: center;
  gap: 4px;
  margin: 18px auto 6px;
  padding: 18px 22px;
  cursor: pointer;
  font: inherit;
  color: var(--ink);
  background: var(--face);
  border: var(--border-w) var(--border-style) var(--edge);
  box-shadow: var(--bevel-out, none);
}
.lv-nopress span {
  display: block;
  min-width: 26px;
  font-family: var(--font-display);
  font-size: 34px;
}

/* Hover and focus do nothing on purpose — see the note on the level. The row
   only comes apart on a real press, and then it stays apart.

   The pivot sits near the base of the glyph, so these topple rather than spin:
   the foot stays planted and the body swings over, which is what an object
   falling on its side actually does.

   --drop is measured and set by app.js at press time, because the input row is
   `margin-top: auto` and the distance to it depends on the window. The value
   here is only a floor for the case where that never ran.

   They have to paint over the response line and the input row they land on, and
   those come later in the document — hence position and z-index. */
.lv-nopress.fell .m {
  --drop: 30px;
  position: relative;
  z-index: 3;
  transform-origin: 50% 85%;
  animation: lv-nopress-drop 900ms linear forwards;
  animation-delay: calc(var(--i, 0) * 60ms);
}

/* Gravity, roughly: fall the length of the window, hit the bar, tip over on the
   first bounce, rock, settle on its side. The rotation is spent across the fall
   rather than applied at the end, so they are visibly going over rather than
   arriving pre-tilted.

   --shift is small on purpose. The four have to keep their columns — the answer
   is read left to right, and a landing that let them drift past each other
   would be a lie about the puzzle. Only every other digit falls, so there is a
   whole standing digit of clearance either side to topple into. */
@keyframes lv-nopress-drop {
  0% {
    transform: translate(0, 0) rotate(0deg);
    animation-timing-function: cubic-bezier(0.45, 0, 1, 0.5);
  }
  44% {
    transform: translate(calc(var(--shift, 0px) * 0.4), var(--drop))
               rotate(calc(var(--tilt, 0deg) * 0.4));
    animation-timing-function: cubic-bezier(0, 0.5, 0.4, 1);
  }
  62% {
    transform: translate(calc(var(--shift, 0px) * 0.8), calc(var(--drop) - 19px))
               rotate(calc(var(--tilt, 0deg) * 0.82));
    animation-timing-function: cubic-bezier(0.45, 0, 1, 0.5);
  }
  80% {
    transform: translate(var(--shift, 0px), var(--drop))
               rotate(calc(var(--tilt, 0deg) * 1.07));
    animation-timing-function: cubic-bezier(0, 0.5, 0.4, 1);
  }
  91% {
    transform: translate(var(--shift, 0px), calc(var(--drop) - 6px))
               rotate(calc(var(--tilt, 0deg) * 0.96));
  }
  100% {
    transform: translate(var(--shift, 0px), var(--drop)) rotate(var(--tilt, 0deg));
  }
}

/* The label and the reward occupy the same slot, so one swaps for the other in
   place rather than the block growing under the box. */
.lv-pressed { display: none; font-size: 12px; color: var(--dim); text-align: center; }
.lv-nopress-warn { text-align: center; }
.lv-nopress.fell ~ .lv-pressed { display: block; }
.lv-nopress.fell ~ .lv-nopress-warn { display: none; }

/* Characters that are present but not printed. Monospace is what makes the
   gaps countable, and countable is what makes it fair. */
.lv-gaps {
  font-family: var(--font-mono);
  font-size: clamp(13px, 2.6vw, 17px);
  white-space: pre;
  overflow-x: auto;
  padding: 26px 4px;
  margin: 0;
  letter-spacing: 0.08em;
}
/* the gaps, lit. They are the data, so they are the only thing on the line
   wearing a colour — and the same colour in every theme, because the marker is
   an annotation on the transmission rather than part of it.

   One lit cell per space, divided by an inset hairline so the run stays
   countable. The divider is `inset` on purpose: a border would widen the cell
   and break the monospace grid the whole level is measured against. */
.lv-gaps .gap {
  font-style: normal;
  background: var(--gap-mark);
  box-shadow:
    inset -1px 0 0 0 var(--face),
    0 0 7px var(--gap-mark);
}

/* A dimensioned drawing, struck. Each run prints its overall above and its parts
   below, with one part blacked out — and because a drawing over-determines its
   own dimensions, the struck figure is still given: overall less the survivors.
   The bar is to scale (flex-grow IS the figure), so the blacked-out segment is
   visibly the right width and the arithmetic can be checked against the picture. */
.lv-drawing {
  display: flex;
  flex-direction: column;
  gap: 20px;
  padding: 14px 2px 6px;
}
.lv-drawing .run { display: flex; flex-direction: column; gap: 5px; }
.lv-drawing .over {
  display: flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--dim);
  letter-spacing: var(--tracking-wide);
}
/* the two halves of a dimension line, with the figure sitting in the gap */
.lv-drawing .over::before,
.lv-drawing .over::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--rule);
}
.lv-drawing .over em { font-style: normal; }
.lv-drawing .bar { display: flex; align-items: stretch; }
.lv-drawing .seg {
  flex: var(--w) 1 0;
  height: 34px;
  border: 1px solid var(--ink);
  border-right-width: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 13px;
}
.lv-drawing .bar .seg:last-child { border-right-width: 1px; }
.lv-drawing .seg em { font-style: normal; }
.lv-drawing .seg.struck b {
  display: block;
  width: 62%;
  height: 12px;
  background: var(--ink);
}

/* PARKED — the caliper component. Built for the second design of level 44
   (microtype under the magnifier) and left behind when that design was cut, the
   same way .lv-abacus and .lv-negative were. It is a good object for any level
   that wants a struck figure on a drawing; it is not dead code. */
.lv-dims {
  display: flex;
  justify-content: center;
  gap: 22px;
  padding: 26px 0;
  flex-wrap: wrap;
}
.lv-dims .cal {
  position: relative;
  width: 78px;
  height: 46px;
  border-left: 1px solid var(--ink);
  border-right: 1px solid var(--ink);
  display: flex;
  align-items: center;
  justify-content: center;
}
.lv-dims .cal::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 1px;
  background: var(--ink);
}
/* The figure sits in a gap in the caliper line, printed at draughtsman's scale.
   `.mtext` (see the microtype note further up) does the actual reducing — this
   only clears a space for it so the strike-through does not run through the
   figure and turn it into a smudge the glass cannot resolve either. */
.lv-dims .cal b {
  position: relative;
  z-index: 1;
  min-width: 38px;
  padding: 3px 0;
  text-align: center;
  background: var(--field);
}

/* A dial on a machine. The face is the only part the client knows about; what
   the machine says at each setting comes back from /api/act. */
.lv-clock {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 18px 0 12px;
}
.lv-clock .face {
  font-family: var(--font-mono);
  font-size: 30px;
  letter-spacing: 0.1em;
  min-width: 106px;
  text-align: center;
  padding: 6px 12px;
  color: var(--ink);
  background: var(--field);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
}

/* Left on the desktop, bottom right — which is exactly where the intrusion
   window parks itself, and clear of the 60px head it collapses into. Level 47
   is the one level where the furniture placement IS the puzzle, so these
   numbers are load-bearing: check them against #intrusion and #intrusion-tab
   before moving either. */
.lv-underneath {
  position: fixed;
  right: 118px;
  bottom: 38px;
  font-family: var(--font-mono);
  font-size: 24px;
  letter-spacing: 0.34em;
  color: var(--dim);
  opacity: 0.8;
}

/* ── the level that cannot be solved ───────────────────────────────────── */

.lv-sanctum {
  position: relative;
  /* Every child here is absolutely positioned, so there is no in-flow content to
     size this box from. `margin: auto` would cancel the flex stretch and collapse
     it to zero width, taking the whole level with it via overflow:hidden. Width
     must be explicit. */
  width: 100%;
  align-self: stretch;
  height: 300px;
  margin: 4px 0;
  overflow: hidden;
}
.lv-sanctum .sig {
  position: absolute;
  left: 50%;
  top: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.lv-sanctum .sig svg {
  stroke: #FF2A2A;
  fill: none;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.lv-sanctum .sig text { fill: #FF2A2A; stroke: none; font-size: 9px; letter-spacing: 0.16em; }
.lv-sanctum .sig-main {
  width: 268px;
  height: 268px;
  margin: -134px 0 0 -134px;
  animation: sanctum-turn 62s linear infinite;
}
.lv-sanctum .sig-main svg { width: 100%; height: 100%; filter: drop-shadow(0 0 12px rgba(255, 42, 42, 0.7)); }
.lv-sanctum .sig-back {
  width: 420px;
  height: 420px;
  margin: -210px 0 0 -210px;
  opacity: 0.22;
  animation: sanctum-turn 148s linear infinite reverse;
}
.lv-sanctum .sig-back svg { width: 100%; height: 100%; }

@keyframes sanctum-turn { to { transform: rotate(360deg); } }

/* Level 47 borrows the sanctum, quieter. The screen is not the puzzle there —
   something is standing in front of the puzzle — so it must not shout. */
.lv-sanctum.quiet { height: 190px; opacity: 0.5; }
.lv-sanctum.quiet .sig-main {
  width: 150px;
  height: 150px;
  margin: -75px 0 0 -75px;
}

.lv-sanctum .glyphs {
  position: absolute;
  font-family: var(--font-mono);
  font-size: 15px;
  line-height: 2;
  letter-spacing: 0.3em;
  color: #C4423A;
  opacity: 0.75;
  text-shadow: 0 0 10px rgba(255, 42, 42, 0.5);
  animation: sanctum-flicker 5.5s steps(1, end) infinite;
}
.lv-sanctum .g-left { left: 2%; top: 12%; }
.lv-sanctum .g-right { right: 2%; top: 12%; animation-delay: 2.3s; }
.lv-sanctum .g-bottom {
  left: 0;
  right: 0;
  bottom: 2%;
  text-align: center;
  font-size: 13px;
  animation-delay: 1.1s;
}

@keyframes sanctum-flicker {
  0%, 100% { opacity: 0.75; }
  47%      { opacity: 0.75; }
  48%      { opacity: 0.25; }
  49%      { opacity: 0.75; }
  73%      { opacity: 0.4; }
  74%      { opacity: 0.75; }
}

/* ---------- the intrusion ----------
 * A small, extremely friendly thing that opens a window on a screen covered in
 * sigils. It is deliberately NOT theme-driven — pastel, rounded, bouncing, in a
 * system UI font — because it did not come from this machine. The wrongness
 * against whatever is behind it is the entire joke. Do not wire it to the theme
 * tokens.
 */

#intrusion {
  position: fixed;
  right: 26px;
  bottom: 26px;
  z-index: 210;
  width: min(310px, calc(100vw - 52px));
  background: #FFFDF8;
  color: #2C2A3D;
  border: 2px solid #2C2A3D;
  border-radius: 20px;
  box-shadow: 0 6px 0 #2C2A3D, 0 22px 48px rgba(0, 0, 0, 0.5);
  font-family: -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
  font-size: 13px;
  letter-spacing: 0;
  overflow: hidden;
  animation: intrusion-in 520ms cubic-bezier(.34, 1.56, .48, 1) both;
}
#intrusion[hidden] { display: none; }

@keyframes intrusion-in {
  from { opacity: 0; transform: translateY(30px) scale(0.82) rotate(-4deg); }
  to   { opacity: 1; transform: none; }
}

/* The top padding is headroom, not taste. #intrusion clips to its border
   radius, and his antenna stands ~19px above the head's own box and then bobs
   another 3px on top of that — at the old 10px it was being sliced off at the
   bulb. Anything that gives .pal a new hat has to come back here. */
#intrusion .itl {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 24px 12px 10px;
  background: #C9F0E4;
  border-bottom: 2px solid #2C2A3D;
  font-size: 13px;
  font-weight: 700;
}
#intrusion .itl > span:nth-child(2) { flex: 1; }

/* the companion itself */
.pal {
  position: relative;
  width: 40px;
  height: 36px;
  flex: none;
  border-radius: 14px 14px 16px 16px;
  background: #8FD9F2;
  border: 2px solid #2C2A3D;
  animation: pal-bob 2.6s ease-in-out infinite;
}
.pal .antenna {
  position: absolute;
  left: 50%;
  top: -11px;
  width: 2px;
  height: 10px;
  background: #2C2A3D;
  transform: translateX(-50%);
}
.pal .antenna::after {
  content: "";
  position: absolute;
  top: -7px;
  left: -3.5px;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: #FFD36E;
  border: 2px solid #2C2A3D;
  animation: pal-blip 1.8s ease-in-out infinite;
}
.pal .eye {
  position: absolute;
  top: 11px;
  width: 6px;
  height: 8px;
  border-radius: 3px;
  background: #2C2A3D;
  animation: pal-blink 4.2s steps(1, end) infinite;
}
.pal .eye.l { left: 9px; }
.pal .eye.r { right: 9px; }
.pal .mouth {
  position: absolute;
  left: 50%;
  bottom: 8px;
  width: 12px;
  height: 6px;
  transform: translateX(-50%);
  border: 2px solid #2C2A3D;
  border-top: none;
  border-radius: 0 0 12px 12px;
}
.pal .cheek {
  position: absolute;
  top: 17px;
  width: 6px;
  height: 4px;
  border-radius: 50%;
  background: #F7A8B8;
  opacity: 0.9;
}
.pal .cheek.l { left: 3px; }
.pal .cheek.r { right: 3px; }

@keyframes pal-bob {
  0%, 100% { transform: translateY(0) rotate(0); }
  50%      { transform: translateY(-3px) rotate(-2deg); }
}
@keyframes pal-blink {
  0%, 92%, 100% { height: 8px; }
  94%, 96%      { height: 1px; }
}
@keyframes pal-blip {
  0%, 100% { background: #FFD36E; }
  50%      { background: #FF9CC2; }
}

#intrusion-close {
  background: none;
  border: none;
  cursor: pointer;
  font: inherit;
  font-size: 19px;
  line-height: 1;
  color: #2C2A3D;
  opacity: 0.5;
  padding: 0 2px;
}
#intrusion-close:hover { opacity: 1; }

#intrusion .ibody {
  padding: 13px 13px 15px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  line-height: 1.5;
}
#intrusion .ibody p {
  margin: 0;
  background: #EFF6FF;
  border: 2px solid #2C2A3D;
  border-radius: 14px 14px 14px 4px;
  padding: 8px 11px;
  max-width: 94%;
  animation: bubble-in 260ms cubic-bezier(.34, 1.56, .48, 1) both;
}
#intrusion .ibody p.think {
  background: #F3EEFF;
  color: #6A6484;
  font-style: italic;
}
@keyframes bubble-in {
  from { opacity: 0; transform: translateY(6px) scale(0.94); }
  to   { opacity: 1; transform: none; }
}

#intrusion .ichoices {
  display: flex;
  gap: 8px;
  padding: 0 13px 14px;
  flex-wrap: wrap;
}
#intrusion .ichoices:empty { display: none; }
#intrusion .ichoices button {
  font: inherit;
  font-size: 12.5px;
  font-weight: 700;
  cursor: pointer;
  padding: 7px 16px;
  border: 2px solid #2C2A3D;
  border-radius: 999px;
  background: #FFE9A8;
  color: #2C2A3D;
  box-shadow: 0 3px 0 #2C2A3D;
  animation: bubble-in 240ms cubic-bezier(.34, 1.56, .48, 1) both;
}
#intrusion .ichoices button:hover { background: #FFD36E; }
#intrusion .ichoices button:active { transform: translateY(3px); box-shadow: none; }

/* minimised — he waits as a floating head */
#intrusion-tab {
  position: fixed;
  right: 30px;
  bottom: 30px;
  z-index: 210;
  width: 60px;
  height: 60px;
  border-radius: 50%;
  border: 2px solid #2C2A3D;
  background: #FFFDF8;
  box-shadow: 0 4px 0 #2C2A3D, 0 14px 30px rgba(0, 0, 0, 0.45);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  animation: intrusion-in 420ms cubic-bezier(.34, 1.56, .48, 1) both;
}
#intrusion-tab[hidden] { display: none; }
#intrusion-tab:hover { background: #FFF6E2; }
#intrusion-tab:active { transform: translateY(4px); box-shadow: 0 0 0 #2C2A3D, 0 8px 18px rgba(0, 0, 0, 0.4); }
#intrusion-tab .pal { width: 34px; height: 30px; }
#intrusion-tab .pal .eye { top: 9px; width: 5px; height: 7px; }
#intrusion-tab .pal .eye.l { left: 7px; }
#intrusion-tab .pal .eye.r { right: 7px; }
#intrusion-tab .pal .mouth { bottom: 6px; width: 10px; height: 5px; }
#intrusion-tab .pal .cheek { top: 14px; }

.lv-files tr.here td { color: var(--ink); font-weight: 700; }

/* ══ THE MACHINE LOSING ITS COMPOSURE ══════════════════════════════════════
   body[data-unrest] is set from an intrusion script, one step at a time, so the
   escalation is authored rather than counted. It is the machine's own furniture
   that shakes — tokens, not hex — because the thing coming apart is the system,
   not the visitor standing in front of it.

   Deliberately NOT a filter on anything clickable: CSS filters do not move hit
   testing, so a warped input bar would refuse the clicks aimed at it (see the
   note on the tube in CLAUDE.md). Everything here is transform and opacity. */
body[data-unrest="1"] #mainwin { animation: unrest-1 2.4s ease-in-out infinite; }
body[data-unrest="1"] #menubar { animation: unrest-slip 5.5s steps(1, end) infinite; }

body[data-unrest="2"] #mainwin { animation: unrest-2 1.15s ease-in-out infinite; }
body[data-unrest="2"] #menubar,
body[data-unrest="2"] #desk-icons { animation: unrest-slip 1.9s steps(1, end) infinite; }
body[data-unrest="2"] #backdrop { animation: unrest-flicker 0.9s steps(1, end) infinite; }
body[data-unrest="2"] .titlebar { animation: unrest-slip 1.3s steps(1, end) infinite; }

@keyframes unrest-1 {
  0%, 100% { transform: translate(0, 0); }
  50%      { transform: translate(0.6px, -0.6px); }
}
@keyframes unrest-2 {
  0%, 100% { transform: translate(0, 0) skewX(0deg); }
  18%      { transform: translate(-3px, 1px) skewX(0.5deg); }
  36%      { transform: translate(2px, -2px) skewX(-0.6deg); }
  54%      { transform: translate(-1px, 2px) skewX(0.3deg); }
  72%      { transform: translate(3px, 0) skewX(-0.2deg); }
}
@keyframes unrest-slip {
  0%, 92%, 100% { transform: translateX(0); opacity: 1; }
  94%           { transform: translateX(-5px); opacity: 0.55; }
  96%           { transform: translateX(4px); }
}
@keyframes unrest-flicker {
  0%, 88%, 100% { opacity: 1; }
  90%           { opacity: 0.25; }
}

/* ══ TEARDOWN ══════════════════════════════════════════════════════════════
   Each piece of the page removed one at a time, from the outside in. `popped`
   is added by the outro runner and cleared when the stage is done, so nothing
   here survives into the next level. */
/* The pieces do not fly away or fall away — they blink out, the way a thing on
   a CRT stops being drawn. Hard cuts, no scale and no blur: an easing curve on
   a disappearing panel reads as an effect, and the effect was competing with
   what it is supposed to be about. The pacing lives in the gap BETWEEN them
   (see the teardown), not in each one. */
.popped {
  animation: pop-away 170ms steps(1, end) forwards;
  pointer-events: none;
}
/* One flicker and gone. It was two, which is a stutter rather than a blink —
   for a hard instant cut with no flicker at all, delete the middle two stops. */
@keyframes pop-away {
  0%,  46%  { opacity: 1; }
  47%, 62%  { opacity: 0; }
  63%, 74%  { opacity: 1; }
  75%, 100% { opacity: 0; }
}

/* Once the furniture is gone the desktop itself has nothing left to be. */
body.blackout #desktop { background: #000; background-image: none; }
body.blackout { background: #000; }
body.blackout #lens { display: none; }

/* ══ THE FINALE STAGE ══════════════════════════════════════════════════════
   Off-theme on purpose, like the panel it replaces: this is not the machine's
   screen any more. Hex rather than tokens for exactly that reason. */
#finale {
  position: fixed;
  inset: 0;
  z-index: 400;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 26px;
  background: #000;
  font-family: -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
}
#finale[hidden] { display: none; }

/* ══ THE WALL ═══════════════════════════════════════════════════════════════
 *
 * Thirty-two screens, at the end of act one, filling the tube one at a time and
 * then connecting. See `deskWall()` in app.js and the WALL block in
 * levels/index.js.
 *
 * ── why there is barely any styling here ───────────────────────────────────
 *
 * Almost every colour on a tile is `var(--token)`, and the token comes from the
 * tile's OWN `data-theme`. `themes.css` scopes its blocks to `[data-theme="x"]`
 * and not `body[data-theme="x"]`, so an attribute on a small div hands it the
 * whole art direction of the level it represents — its desktop colour, its
 * window face, its rule colour, its scanline overlay, its typeface. The wall is
 * thirty-two real screens rather than thirty-two impressions of screens, and it
 * cost one attribute.
 *
 * The consequence: do not put a literal colour in this block unless it is one
 * of the two below that are deliberately outside the theme system.
 *
 * ── the colour that is not themed ──────────────────────────────────────────
 *
 * The connect green, and the glow it throws. Every tile lights the SAME green,
 * which is the whole reading of the scene: thirty-two machines in eleven
 * different art directions, and one thing arriving in all of them. Themed, each
 * screen would light in its own colour and the picture would be thirty-two
 * computers having a nice time.
 *
 * It is act one's terminal green on purpose — the first skin in the game that
 * was ever green, and the one the player has been looking at since desktop 2. */
#wall {
  position: absolute;
  inset: 0;
  z-index: 0;
  display: grid;
  grid-template-columns: repeat(8, 1fr);
  grid-auto-rows: 1fr;
  gap: 5px;
  padding: 5px;
  opacity: 0;
  transition: opacity 700ms ease;
}
#wall.up { opacity: 1; }
/* He comes back over the top of it and it gets out of the way. */
#wall.dim { opacity: 0.34; transition: opacity 900ms ease; }

.wt {
  position: relative;
  overflow: hidden;
  background: var(--desk, #111);
  background-image: var(--desk-img, none);
  border: 1px solid var(--rule, #333);
  border-radius: var(--radius, 0);
  font-family: var(--font, monospace);
  opacity: 0;
  transform: scale(0.88);
  transition: opacity 260ms ease, transform 260ms ease;
}
.wt.on { opacity: 1; transform: scale(1); }

/* The scanline or paper overlay the real screen wears, at the real strength. */
.wt::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: var(--overlay-img, none);
  background-size: var(--overlay-size, auto);
  mix-blend-mode: var(--overlay-blend, normal);
  opacity: var(--overlay-opacity, 0);
}

/* A login box, because that is what every one of these screens was.
 *
 * The tile used to hold three grey bars standing in for body text, which is the
 * usual way to draw a screen too small to read. It was the wrong drawing: these
 * are not thirty-two documents, they are thirty-two sign-ins, and act one's
 * windows say so in their own captions — every one of them is called "Logging
 * in as <somebody>". So the tile says the same thing the window said, and the
 * word CONNECTED lands in the space the person's name was occupying.
 *
 * That swap is the whole beat in one gesture: the name goes, the connection
 * stays. Nothing else on the tile moves. */
.wt-win {
  position: absolute;
  left: 10%;
  right: 10%;
  top: 22%;
  background: var(--face, #222);
  border: 1px solid var(--edge, #555);
  border-radius: var(--radius, 0);
  box-shadow: var(--bevel-out, 0 0 0 0 transparent);
}
.wt-bar {
  display: flex;
  align-items: center;
  gap: 2px;
  height: 7px;
  padding: 0 2px;
  background: var(--accent, #444);
  border-bottom: 1px solid var(--edge, #555);
}
/* The title-bar light, at the size the real one would be from here. */
.wt-bar::before {
  content: "";
  width: 3px;
  height: 3px;
  border: 1px solid var(--tbtn-edge, var(--edge, #555));
  background: var(--tbtn-bg, transparent);
  opacity: var(--tbtn-opacity, 1);
}

.wt-body {
  position: relative;
  padding: 5px 4px 6px;
  text-align: center;
}

.wt-name {
  display: block;
  font-family: var(--font-display, monospace);
  font-size: clamp(5px, 0.62vw, 9px);
  letter-spacing: 0.08em;
  line-height: 1.25;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--ink, #999);
}

/* ---- the mark: what was on that desktop ----
 *
 * Twenty-six little drawings, one per kind of puzzle, keyed off `data-mark`.
 * See WALL_MARK in levels/index.js for which level gets which and why.
 *
 * Everything is three absolutely-positioned spans inside an 17px box, plus
 * ::before/::after where a shape needs four or five parts. They are drawn in
 * the tile's OWN theme tokens, so the bin on a system7 desktop and the bin on
 * an archive desktop are the same bin in two different eras — which is the
 * entire reason the wall is worth looking at.
 *
 * Keep them at this weight. A mark is on screen at postage-stamp size for nine
 * seconds and is recognised or not in about a fifth of a second; detail past
 * that is detail nobody will ever resolve, and it costs legibility in the
 * skins with the least contrast (plate, risograph). */
.wt-art { position: relative; height: 17px; margin: 4px 7px 2px; }
.wt-art i { position: absolute; display: block; background: var(--dim, #777); }
.wt-art::before, .wt-art::after { content: ""; position: absolute; display: block; }

/* a menu bar with something pulled down out of it */
[data-mark="menu"] i:nth-child(1) { left: 0; right: 0; top: 0; height: 3px; background: var(--accent, #555); }
[data-mark="menu"] i:nth-child(2) { left: 12%; top: 3px; width: 38%; height: 13px; background: var(--face, #222); border: 1px solid var(--edge, #666); }
[data-mark="menu"] i:nth-child(3) { left: 16%; top: 7px; width: 26%; height: 1px; background: var(--ink, #999); }

/* one indicator, lit */
[data-mark="lamp"] i:nth-child(1) { left: 50%; top: 4px; width: 9px; height: 9px; margin-left: -4.5px; border-radius: 50%; background: var(--seg-on, var(--ink, #ccc)); box-shadow: var(--seg-glow, 0 0 5px rgba(255,255,255,0.5)); }
[data-mark="lamp"] i:nth-child(2), [data-mark="lamp"] i:nth-child(3) { display: none; }

/* a page with writing on it */
[data-mark="sheet"] i { left: 0; height: 2px; }
[data-mark="sheet"] i:nth-child(1) { top: 3px; width: 78%; }
[data-mark="sheet"] i:nth-child(2) { top: 8px; width: 92%; }
[data-mark="sheet"] i:nth-child(3) { top: 13px; width: 46%; }

/* the Trash */
[data-mark="bin"] i:nth-child(1) { left: 50%; top: 4px; width: 13px; height: 12px; margin-left: -6.5px; background: none; border: 1px solid var(--ink, #999); border-top: none; }
[data-mark="bin"] i:nth-child(2) { left: 50%; top: 2px; width: 17px; height: 2px; margin-left: -8.5px; background: var(--ink, #999); }
[data-mark="bin"] i:nth-child(3) { left: 50%; top: 0; width: 7px; height: 2px; margin-left: -3.5px; background: var(--ink, #999); }

/* text selected out of the paper it was hiding in */
[data-mark="select"] i:nth-child(1) { left: 14%; top: 4px; width: 72%; height: 10px; background: var(--accent, #456); }
[data-mark="select"] i:nth-child(2) { left: 20%; top: 8px; width: 46%; height: 2px; background: var(--accent-ink, #fff); opacity: 0.85; }
[data-mark="select"] i:nth-child(3) { display: none; }

/* a reel, with records on it */
[data-mark="spool"] i:nth-child(1) { left: 50%; top: 1px; width: 15px; height: 15px; margin-left: -7.5px; border-radius: 50%; background: none; border: 1px solid var(--ink, #999); }
[data-mark="spool"] i:nth-child(2) { left: 50%; top: 6px; width: 5px; height: 5px; margin-left: -2.5px; border-radius: 50%; background: var(--ink, #999); }
[data-mark="spool"] i:nth-child(3) { display: none; }

/* a window shorter than what is in it */
[data-mark="scroll"] i:nth-child(1) { left: 0; top: 1px; width: 74%; height: 15px; background: none; border: 1px solid var(--rule, #555); }
[data-mark="scroll"] i:nth-child(2) { right: 0; top: 1px; width: 5px; height: 15px; background: var(--field, none); border: 1px solid var(--rule, #555); }
[data-mark="scroll"] i:nth-child(3) { right: 1px; top: 8px; width: 5px; height: 6px; background: var(--ink, #999); }

/* a list of records, one per row */
[data-mark="rows"] i { left: 0; right: 0; height: 1px; background: var(--rule, #555); }
[data-mark="rows"] i:nth-child(1) { top: 3px; }
[data-mark="rows"] i:nth-child(2) { top: 8px; }
[data-mark="rows"] i:nth-child(3) { top: 13px; }
[data-mark="rows"]::before { left: 34%; top: 1px; width: 1px; height: 15px; background: var(--rule, #555); }

/* outside the window entirely */
[data-mark="tab"] i:nth-child(1) { left: 8%; top: 0; width: 34%; height: 6px; background: var(--face, #222); border: 1px solid var(--edge, #666); border-bottom: none; }
[data-mark="tab"] i:nth-child(2) { left: 0; right: 0; top: 6px; height: 11px; background: var(--face, #222); border: 1px solid var(--edge, #666); }
[data-mark="tab"] i:nth-child(3) { display: none; }

/* a field of dots, most of which ignore you */
[data-mark="noise"] i:nth-child(1) {
  left: 0; right: 0; top: 1px; height: 15px; background: none;
  background-image: radial-gradient(var(--ink, #999) 0.6px, transparent 0.7px);
  background-size: 4px 4px;
  opacity: 0.85;
}
[data-mark="noise"] i:nth-child(2), [data-mark="noise"] i:nth-child(3) { display: none; }

/* the engraving, and the engraving the right way round */
[data-mark="mirror"] i:nth-child(1) { left: 18%; top: 4px; width: 0; height: 0; background: none; border: 5px solid transparent; border-left-color: var(--ink, #999); }
[data-mark="mirror"] i:nth-child(2) { right: 18%; top: 4px; width: 0; height: 0; background: none; border: 5px solid transparent; border-right-color: var(--ink, #999); }
[data-mark="mirror"] i:nth-child(3) { left: 50%; top: 0; width: 1px; height: 17px; background: var(--edge, #666); }

/* two sheets, out of register */
[data-mark="stack"] i:nth-child(1) { left: 16%; top: 0; width: 46%; height: 12px; background: var(--face, #222); border: 1px solid var(--rule, #555); }
[data-mark="stack"] i:nth-child(2) { left: 34%; top: 5px; width: 46%; height: 12px; background: var(--face, #222); border: 1px solid var(--ink, #999); }
[data-mark="stack"] i:nth-child(3) { display: none; }

/* the glass */
[data-mark="glass"] i:nth-child(1) { left: 34%; top: 0; width: 12px; height: 12px; border-radius: 50%; background: none; border: 1.5px solid var(--ink, #999); }
[data-mark="glass"] i:nth-child(2) { left: 56%; top: 11px; width: 7px; height: 2px; background: var(--ink, #999); transform: rotate(42deg); transform-origin: left center; }
[data-mark="glass"] i:nth-child(3) { display: none; }

/* a keyboard that is wired wrong */
[data-mark="keys"] i { top: 5px; width: 6px; height: 6px; background: var(--face, #222); border: 1px solid var(--ink, #999); }
[data-mark="keys"] i:nth-child(1) { left: 26%; }
[data-mark="keys"] i:nth-child(2) { left: 44%; }
[data-mark="keys"] i:nth-child(3) { left: 62%; }

/* a page that is not there */
[data-mark="broken"] i:nth-child(1) { left: 14%; top: 0; width: 30%; height: 15px; background: var(--face, #222); border: 1px solid var(--rule, #555); }
[data-mark="broken"] i:nth-child(2) { right: 14%; top: 3px; width: 30%; height: 15px; background: var(--face, #222); border: 1px solid var(--rule, #555); }
[data-mark="broken"] i:nth-child(3) { display: none; }

/* four plates, in the four plate colours — the real tokens, see themes.css */
[data-mark="plates"] i:nth-child(1) { left: 16%; top: 5px; width: 7px; height: 7px; background: var(--plate-square); }
[data-mark="plates"] i:nth-child(2) { left: 36%; top: 5px; width: 6px; height: 6px; background: var(--plate-diamond); transform: rotate(45deg); }
[data-mark="plates"] i:nth-child(3) { left: 56%; top: 5px; width: 7px; height: 7px; border-radius: 50%; background: var(--plate-circle); }
[data-mark="plates"]::after { right: 16%; top: 5px; width: 0; height: 0; border: 4px solid transparent; border-bottom-color: var(--plate-triangle); }

/* a panel refreshing one column at a time */
[data-mark="grid"] i { top: 1px; width: 4px; height: 15px; background: var(--seg-off, var(--rule, #555)); }
[data-mark="grid"] i:nth-child(1) { left: 22%; }
[data-mark="grid"] i:nth-child(2) { left: 40%; background: var(--seg-on, var(--ink, #ccc)); box-shadow: var(--seg-glow, none); }
[data-mark="grid"] i:nth-child(3) { left: 58%; }
[data-mark="grid"]::before { left: 76%; top: 1px; width: 4px; height: 15px; background: var(--seg-off, var(--rule, #555)); }

/* the filed copy, coming off the platen */
[data-mark="print"] i:nth-child(1) { left: 16%; right: 16%; top: 0; height: 4px; background: var(--accent, #555); border: 1px solid var(--edge, #666); }
[data-mark="print"] i:nth-child(2) { left: 26%; right: 26%; top: 6px; height: 11px; background: var(--face, #fff); border: 1px solid var(--ink, #999); }
[data-mark="print"] i:nth-child(3) { left: 32%; right: 32%; top: 10px; height: 1px; background: var(--dim, #777); }

/* a step wedge */
[data-mark="steps"] i:nth-child(1) { left: 6%; right: 6%; top: 4px; height: 9px; background: linear-gradient(to right, var(--face, #222) 0%, var(--dim, #777) 50%, var(--ink, #ccc) 100%); border: 1px solid var(--rule, #555); }
[data-mark="steps"] i:nth-child(2), [data-mark="steps"] i:nth-child(3) { display: none; }

/* the one control on the screen */
[data-mark="button"] i:nth-child(1) { left: 50%; top: 2px; width: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%; background: var(--alert, var(--accent, #a44)); border: 1.5px solid var(--ink, #999); }
[data-mark="button"] i:nth-child(2), [data-mark="button"] i:nth-child(3) { display: none; }

/* words, unevenly spaced */
[data-mark="gaps"] i { top: 7px; height: 3px; }
[data-mark="gaps"] i:nth-child(1) { left: 2%; width: 18%; }
[data-mark="gaps"] i:nth-child(2) { left: 32%; width: 26%; }
[data-mark="gaps"] i:nth-child(3) { left: 74%; width: 22%; }

/* a dimension, with its overall above it */
[data-mark="dims"] i:nth-child(1) { left: 10%; right: 10%; top: 8px; height: 1px; background: var(--ink, #999); }
[data-mark="dims"] i:nth-child(2) { left: 10%; top: 4px; width: 1px; height: 9px; background: var(--ink, #999); }
[data-mark="dims"] i:nth-child(3) { right: 10%; top: 4px; width: 1px; height: 9px; background: var(--ink, #999); }
[data-mark="dims"]::before { left: 10%; right: 10%; top: 2px; height: 1px; background: var(--rule, #555); }

/* the rota */
[data-mark="clock"] i:nth-child(1) { left: 50%; top: 1px; width: 15px; height: 15px; margin-left: -7.5px; border-radius: 50%; background: none; border: 1px solid var(--ink, #999); }
[data-mark="clock"] i:nth-child(2) { left: 50%; top: 5px; width: 1px; height: 4px; background: var(--ink, #999); }
[data-mark="clock"] i:nth-child(3) { left: 50%; top: 8px; width: 5px; height: 1px; background: var(--ink, #999); }

/* five windows onto one sheet */
[data-mark="panes"] i { top: 2px; width: 8px; height: 13px; background: var(--face, #222); border: 1px solid var(--edge, #666); }
[data-mark="panes"] i:nth-child(1) { left: 8%; }
[data-mark="panes"] i:nth-child(2) { left: 28%; }
[data-mark="panes"] i:nth-child(3) { left: 48%; }
[data-mark="panes"]::before { left: 68%; top: 2px; width: 8px; height: 13px; background: var(--face, #222); border: 1px solid var(--edge, #666); }
[data-mark="panes"]::after { left: 88%; top: 2px; width: 8px; height: 13px; background: var(--face, #222); border: 1px solid var(--edge, #666); }

/* the counter that never stopped counting */
[data-mark="bar"] i:nth-child(1) { left: 0; right: 0; top: 4px; height: 9px; background: var(--face, #222); border: 1px solid var(--rule, #555); }
[data-mark="bar"] i:nth-child(2) { right: 3px; top: 6px; width: 16px; height: 5px; background: var(--ink, #999); }
[data-mark="bar"] i:nth-child(3) { left: 3px; top: 6px; width: 8px; height: 5px; background: var(--dim, #777); }

/* him */
[data-mark="face"] i:nth-child(1) { left: 38%; top: 4px; width: 3px; height: 4px; border-radius: 1px; background: var(--ink, #ccc); }
[data-mark="face"] i:nth-child(2) { right: 38%; top: 4px; width: 3px; height: 4px; border-radius: 1px; background: var(--ink, #ccc); }
[data-mark="face"] i:nth-child(3) { left: 50%; top: 11px; width: 9px; height: 4px; margin-left: -4.5px; background: none; border: 1.5px solid var(--ink, #ccc); border-top: none; border-radius: 0 0 9px 9px; }
[data-mark="face"]::before { left: 50%; top: 0; width: 1px; height: 4px; margin-left: -0.5px; background: var(--ink, #ccc); }
[data-mark="face"]::after { left: 50%; top: -3px; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: var(--warn, #e0c24b); }

/* The field the name was typed into. It is empty on every tile, which is the
   quiet part: nobody signed in here, something else did. */
.wt-field {
  margin: 4px 6px 0;
  height: 4px;
  background: var(--field, transparent);
  border: 1px solid var(--field-edge, var(--rule, #444));
  border-radius: var(--radius, 0);
}

/* ---- taken ----
 *
 * The whole tile goes under a green wash, everything on it fades back behind
 * that, and one word sits in the middle of the screen — not inside the window
 * that is on it.
 *
 * It was briefly done the other way, with CONNECTED cross-fading into the space
 * the name occupied. That read as a form filling itself in. The wash reads as
 * something arriving and taking the machine, which is what is happening, and it
 * is legible at a glance across thirty-two tiles at once — a word swapped inside
 * a window six pixels tall is not. The sign-in box and the mark stay visible
 * underneath, dimmed, so you can still see whose desk it was and what was on it.
 */
.wt-state {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: clamp(6px, 0.66vw, 9px);
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: #8CFFCB;
  background: rgba(4, 28, 18, 0.62);
  opacity: 0;
  transition: opacity 220ms ease;
}
.wt.lit { border-color: #4BE0A8; }
.wt.lit .wt-state { opacity: 1; }

/* One flash as it takes, then it settles. A connection is an event. */
.wt.lit { animation: wt-take 520ms ease-out forwards; }
@keyframes wt-take {
  0%   { box-shadow: 0 0 0 1px #4BE0A8, 0 0 0 rgba(75, 224, 168, 0); }
  22%  { box-shadow: 0 0 0 2px #CFFFE9, 0 0 42px rgba(140, 255, 203, 0.95); }
  100% { box-shadow: 0 0 0 1px #4BE0A8, 0 0 18px rgba(75, 224, 168, 0.55); }
}

/* The last tile is the player's own machine and it is held back on its own.
   It lands harder because by then there is nothing else moving. */
.wt.mine.lit { animation-duration: 900ms; }
.wt.mine.lit .wt-state { letter-spacing: 0.34em; }

@media (max-width: 900px) { #wall { grid-template-columns: repeat(4, 1fr); } }
@media (max-width: 480px) { #wall { grid-template-columns: repeat(3, 1fr); } }

@media (prefers-reduced-motion: reduce) {
  .wt { transition: opacity 120ms linear; transform: none; }
  .wt.on { transform: none; }
  .wt.lit { animation: none; box-shadow: 0 0 0 1px #4BE0A8; }
}

#finale-inner {
  position: relative;   /* above #wall, which is placed behind it */
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 26px;
  width: 100%;
}

/* He starts small and low — roughly where the minimised tab sat — and takes his
   time coming to the middle. Then he never stops growing: `growing` runs for
   twenty seconds, slowly enough that you are not sure it is happening. */
/* The head is 40x36 in layout and four to six times that on screen, and a
   transform reserves no space at all — so without this margin the caption
   renders straight through his teeth. Enough room for the biggest he gets,
   plus the horns, which stand above the head entirely. */
#finale-pal {
  margin: 76px 0 84px;
  /* .pal idles with `pal-bob`, which animates transform — and a running
     animation outranks normal rules, so it was quietly overriding every scale
     below. The head sat at 1x through the arrival and the whole growth, then
     `reforming` swapped pal-bob for head-drain, the rules took effect, and it
     jumped to full size at the exact moment it morphed. He does not bob here;
     he looms. */
  animation: none;
  transform: translate(38vw, 34vh) scale(0.55);
  opacity: 0;
  transition:
    transform 3000ms cubic-bezier(0.22, 0.61, 0.36, 1),
    opacity 1100ms linear;
}
#finale.arrived #finale-pal {
  transform: translate(0, 0) scale(3);
  opacity: 1;
}
/* A small, slow creep — the point is that you are not sure it is happening, not
   that he ends up large. An earlier pass ran to 6x, which meant he was already
   big by the time the face came off and the parts flew past the edge of the
   screen. Keep this range narrow. */
#finale.growing #finale-pal {
  transform: translate(0, 0) scale(3.5);
  transition: transform 20000ms linear, opacity 1100ms linear;
}
/* `holding` carries no transform of its own on purpose. The freeze is done in
   reformHead() by pinning the *current* computed matrix inline, so the growth
   simply stops where it is. Naming a fixed scale here instead made him deflate
   back down to it, which read worse than the growth it was meant to stop. */

#finale-say {
  min-height: 2.2em;
  max-width: min(520px, 84vw);
  padding: 0 16px;
  text-align: center;
  color: #FFFDF8;
  font-size: 15px;
  line-height: 1.5;
  opacity: 0;
  transition: opacity 600ms linear;
}
#finale.arrived #finale-say { opacity: 1; }
/* One line at a time. The panel keeps a transcript because it is a chat; this
   is not a chat, and a stack of everything he has said undercuts the last
   thing he says. */
#finale-say p { margin: 0; }
#finale-say p:not(:last-child) { display: none; }
#finale-say p.think { font-style: italic; opacity: 0.7; }
#finale-say.gone { opacity: 0; }

#finale-choices { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; }
#finale-choices:empty { display: none; }
#finale-choices button {
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  padding: 9px 26px;
  cursor: pointer;
  color: #2C2A3D;
  background: #FFD36E;
  border: 2px solid #2C2A3D;
  border-radius: 14px;
  box-shadow: 0 4px 0 #2C2A3D;
}
#finale-choices button:hover { background: #FFE7A8; }
#finale-choices button:active { transform: translateY(3px); box-shadow: none; }

/* ── THE REFORM ────────────────────────────────────────────────────────────
   His face comes off. Each part leaves on its own path — drifting, turning,
   growing, shrinking, nowhere near where it belongs — and then they all come
   back and settle into an arrangement that is not the one they left.

   One animation per part, running the whole way out and back, rather than a
   scatter class followed by a settle class: two classes would race, and a part
   caught mid-flight by the second one would snap. `forwards` holds each part in
   its new place, so the face that is standing there at the end is the face
   these parts landed in.

   The head's own transform is NOT touched here — that belongs to #finale-pal's
   growth transition, and an animation on the same property would fight it.
   Everything below is the children. */
#finale-pal.reforming { animation: head-drain 4600ms ease-in-out forwards; }
#finale-pal.reforming .eye { animation-fill-mode: forwards; }
#finale-pal.reforming .eye.l { animation: part-eye-l 4600ms cubic-bezier(0.4, 0, 0.3, 1) forwards; }
#finale-pal.reforming .eye.r { animation: part-eye-r 4600ms cubic-bezier(0.4, 0, 0.3, 1) forwards; }
#finale-pal.reforming .mouth { animation: part-mouth 4600ms cubic-bezier(0.4, 0, 0.3, 1) forwards; }
#finale-pal.reforming .cheek.l { animation: part-cheek-l 4600ms ease-in-out forwards; }
#finale-pal.reforming .cheek.r { animation: part-cheek-r 4600ms ease-in-out forwards; }
#finale-pal.reforming .antenna { animation: part-antenna 4600ms cubic-bezier(0.4, 0, 0.3, 1) forwards; }
#finale-pal.reforming .antenna::after { animation: part-bulb 4600ms ease-in-out forwards; }

/* What the parts land as is NOT his face rearranged — it is a different face
   built out of his parts, and every one of them has a new job:
     eyes    → two narrow slashes, angled down toward the middle
     bulb    → a third eye, dropped from above the head onto the brow
     cheeks  → horns, moved to the top of the head and pointed up
     mouth   → a wide jagged maw, grinning. Not a frown, deliberately: a frown
               is ARP being sad, and this is not ARP.
   Shapes that cannot interpolate (the clip-paths) are set mid-flight, while the
   part is small and spinning, so nothing snaps once it settles. */
@keyframes head-drain {
  0%   { background: #8FD9F2; border-color: #2C2A3D; border-radius: 14px 14px 16px 16px;
         box-shadow: 0 0 0 0 transparent; }
  40%  { background: #A9CFD6; border-radius: 20px 8px 22px 10px; }
  70%  { background: #6E6367; border-radius: 8px 20px 10px 22px; }
  100% { background: #241C1F; border-color: #4A1417; border-radius: 11px 11px 17px 17px;
         box-shadow: 0 0 14px 2px rgba(255, 42, 42, 0.4); }
}

@keyframes part-eye-l {
  0%   { transform: translate(0, 0) rotate(0) scale(1);
         width: 6px; height: 8px; border-radius: 3px; background: #2C2A3D;
         box-shadow: 0 0 0 0 transparent; }
  26%  { transform: translate(-24px, -15px) rotate(-140deg) scale(2.1); }
  48%  { transform: translate(-6px, 19px) rotate(70deg) scale(0.35); }
  72%  { transform: translate(11px, -9px) rotate(-30deg) scale(1.6);
         background: #FF2A2A; }
  100% { transform: translate(-5px, 4px) rotate(21deg) scale(1);
         width: 13px; height: 3px; border-radius: 1px; background: #FF2A2A;
         box-shadow: 0 0 5px 1px rgba(255, 42, 42, 0.95); }
}
@keyframes part-eye-r {
  0%   { transform: translate(0, 0) rotate(0) scale(1);
         width: 6px; height: 8px; border-radius: 3px; background: #2C2A3D;
         box-shadow: 0 0 0 0 transparent; }
  26%  { transform: translate(21px, 17px) rotate(160deg) scale(0.4); }
  48%  { transform: translate(4px, -21px) rotate(-90deg) scale(2.3); }
  72%  { transform: translate(-13px, 5px) rotate(40deg) scale(0.7);
         background: #FF2A2A; }
  100% { transform: translate(5px, 4px) rotate(-21deg) scale(1);
         width: 13px; height: 3px; border-radius: 1px; background: #FF2A2A;
         box-shadow: 0 0 5px 1px rgba(255, 42, 42, 0.95); }
}

/* the maw. The clip-path is a row of teeth and arrives at 62%, mid-tumble. */
@keyframes part-mouth {
  0%   { transform: translateX(-50%) translateY(0) rotate(0) scale(1);
         width: 12px; height: 6px; border-radius: 0 0 12px 12px;
         border-width: 0 2px 2px 2px; border-color: #2C2A3D; background: transparent; }
  24%  { transform: translateX(-50%) translateY(-30px) rotate(150deg) scale(1.8); }
  50%  { transform: translateX(-50%) translateY(14px) rotate(-40deg) scale(0.5); }
  62%  { clip-path: polygon(0% 0%, 100% 0%, 100% 40%, 91% 100%, 83% 38%, 74% 92%,
           66% 36%, 57% 100%, 48% 38%, 40% 96%, 31% 36%, 22% 100%, 14% 38%, 5% 93%, 0% 40%);
         background: #120A0C; border-width: 0; }
  74%  { transform: translateX(-50%) translateY(-7px) rotate(22deg) scale(1.3); }
  100% { transform: translateX(-50%) translateY(5px) rotate(0) scale(1);
         width: 27px; height: 11px; border-radius: 0; border-width: 0;
         background: #120A0C;
         clip-path: polygon(0% 0%, 100% 0%, 100% 40%, 91% 100%, 83% 38%, 74% 92%,
           66% 36%, 57% 100%, 48% 38%, 40% 96%, 31% 36%, 22% 100%, 14% 38%, 5% 93%, 0% 40%); }
}

/* the horns. Same two dots that used to be his blush. */
@keyframes part-cheek-l {
  0%   { transform: translate(0, 0) rotate(0) scale(1); opacity: 0.9;
         width: 6px; height: 4px; border-radius: 50%; background: #F7A8B8; }
  30%  { transform: translate(-30px, 12px) rotate(80deg) scale(2.6); opacity: 0.9; }
  58%  { transform: translate(-14px, -18px) rotate(-50deg) scale(0.4);
         clip-path: polygon(50% 0%, 100% 100%, 0% 100%); border-radius: 0;
         background: #3A1417; }
  100% { transform: translate(-1px, -33px) rotate(-26deg) scale(1); opacity: 1;
         width: 9px; height: 15px; border-radius: 0; background: #3A1417;
         clip-path: polygon(50% 0%, 100% 100%, 0% 100%); }
}
@keyframes part-cheek-r {
  0%   { transform: translate(0, 0) rotate(0) scale(1); opacity: 0.9;
         width: 6px; height: 4px; border-radius: 50%; background: #F7A8B8; }
  30%  { transform: translate(26px, -16px) rotate(-110deg) scale(0.3); opacity: 0.9; }
  58%  { transform: translate(9px, 20px) rotate(60deg) scale(2.2);
         clip-path: polygon(50% 0%, 100% 100%, 0% 100%); border-radius: 0;
         background: #3A1417; }
  100% { transform: translate(1px, -33px) rotate(26deg) scale(1); opacity: 1;
         width: 9px; height: 15px; border-radius: 0; background: #3A1417;
         clip-path: polygon(50% 0%, 100% 100%, 0% 100%); }
}

/* The stalk carries the bulb down onto the brow and then stops being a stalk.
   No scale on this one — it has a child, and scaling it would scale the eye. */
@keyframes part-antenna {
  0%   { transform: translateX(-50%) translate(0, 0) rotate(0); background: #2C2A3D; }
  28%  { transform: translateX(-50%) translate(18px, 26px) rotate(120deg); }
  55%  { transform: translateX(-50%) translate(-22px, 8px) rotate(-60deg);
         background: transparent; }
  100% { transform: translateX(-50%) translate(0, 19px) rotate(0); background: transparent; }
}
@keyframes part-bulb {
  0%   { background: #FFD36E; border-color: #2C2A3D; transform: scale(1);
         width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 0 0 transparent; }
  40%  { background: #FFD36E; transform: scale(2.2); }
  70%  { background: #FF2A2A; }
  100% { background: #FF2A2A; border-color: #4A1417; transform: scale(1);
         width: 7px; height: 11px; border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
         box-shadow: 0 0 6px 1px rgba(255, 42, 42, 0.9); }
}

/* ── THE TUBE SHUTS OFF ────────────────────────────────────────────────────
   The picture collapses to a bright horizontal line, the line holds for a beat,
   then it closes to nothing. Same trick a CRT does when it loses the field. */
#finale.shutdown #finale-inner { animation: tv-collapse 1000ms cubic-bezier(0.6, 0, 0.9, 0.2) forwards; }
#finale.shutdown::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 2px;
  background: #FFFFFF;
  box-shadow: 0 0 22px 6px rgba(255, 255, 255, 0.85);
  animation: tv-line 1000ms cubic-bezier(0.6, 0, 0.9, 0.2) forwards;
}
@keyframes tv-collapse {
  0%   { transform: scale(1, 1); filter: brightness(1); opacity: 1; }
  38%  { transform: scale(1.01, 0.006); filter: brightness(5); opacity: 1; }
  56%  { transform: scale(1.01, 0.006); filter: brightness(5); opacity: 1; }
  100% { transform: scale(0, 0.006); filter: brightness(6); opacity: 0; }
}
@keyframes tv-line {
  0%   { opacity: 0; transform: scaleX(0.2); }
  36%  { opacity: 0; transform: scaleX(0.2); }
  42%  { opacity: 1; transform: scaleX(1); }
  60%  { opacity: 1; transform: scaleX(1); }
  100% { opacity: 0; transform: scaleX(0); }
}

/* ══ A SCREEN THAT MEANS IT ════════════════════════════════════════════════
   The wild variant of the sanctum. Same furniture as the quiet one, turned all
   the way up: the mark counter-rotates against its own backing copy, the glyph
   columns crawl, and a sweep passes over the whole thing. It has to read as a
   screen with a real puzzle on it — the joke does not work if it looks like a
   cutscene. */
.lv-sanctum.wild { height: 300px; opacity: 1; }
.lv-sanctum.wild .sig-main { animation: sanctum-turn 41s linear infinite; }
.lv-sanctum.wild .sig-back {
  animation: sanctum-turn-back 27s linear infinite, sanctum-throb 5.5s ease-in-out infinite;
}
.lv-sanctum.wild::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(180deg, transparent 0%, rgba(255, 42, 42, 0.13) 48%, transparent 100%);
  animation: sanctum-sweep 6.5s linear infinite;
}
@keyframes sanctum-turn { to { transform: rotate(360deg); } }
@keyframes sanctum-turn-back { to { transform: rotate(-360deg); } }
@keyframes sanctum-throb {
  0%, 100% { opacity: 0.32; }
  50%      { opacity: 0.7; }
}
@keyframes sanctum-sweep {
  0%   { transform: translateY(-100%); }
  100% { transform: translateY(100%); }
}

/* The four panels that make it look answerable. They are a real arrangement —
   four things, four columns, left to right, exactly the grammar act I taught —
   and they do not resolve to anything. That is the level. */
.lv-rite {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 10px;
  margin-top: 14px;
}
.lv-rite .cell {
  position: relative;
  padding: 10px 6px 18px;
  text-align: center;
  border: var(--border-w) var(--border-style) var(--rule);
  font-family: var(--font-display);
  font-size: 22px;
  line-height: 1.35;
  color: var(--ink);
  animation: rite-crawl 7s steps(1, end) infinite;
}
.lv-rite .cell:nth-child(2) { animation-delay: 1.4s; }
.lv-rite .cell:nth-child(3) { animation-delay: 3.1s; }
.lv-rite .cell:nth-child(4) { animation-delay: 4.7s; }
.lv-rite .cell b {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 4px;
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 400;
  letter-spacing: var(--tracking-wide);
  color: var(--dim);
}
@keyframes rite-crawl {
  0%, 82%, 100% { opacity: 1; transform: translateY(0); }
  86%           { opacity: 0.45; transform: translateY(-2px); }
  90%           { opacity: 1; transform: translateY(1px); }
}

/* A plain-text file, shown as what it is. Long enough to need wrapping and
   selectable, because the instruction is to copy and paste it. */
.doc-plain {
  margin: 0;
  padding: 10px 12px;
  background: var(--field);
  color: var(--field-ink);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in, none);
  font-family: var(--font-mono);
  font-size: 13px;
  line-height: 1.7;
  letter-spacing: 0.06em;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  user-select: all;
}

@media (prefers-reduced-motion: reduce) {
  .lv-sanctum .sig-main,
  .lv-sanctum .sig-back,
  .lv-sanctum .glyphs { animation: none; }
  #intrusion,
  #intrusion-tab,
  #intrusion .ichoices button,
  #intrusion .ibody p,
  .pal,
  .pal .eye,
  .pal .antenna::after { animation: none; }

  /* The fall is decoration; where they end up is the answer. Skip the animation
     and put them straight on the floor — never leave a reduced-motion player
     looking at an unsolvable screen. */
  .lv-nopress.fell .m {
    animation: none;
    transform: translate(var(--shift, 0px), var(--drop)) rotate(var(--tilt, 0deg));
  }

  /* The unrest and the rite are atmosphere; the teardown and the finale are the
     act break, so those keep their timing but lose their motion. The head still
     arrives in the middle and still turns — it just does not travel there. */
  body[data-unrest] #mainwin,
  body[data-unrest] #menubar,
  body[data-unrest] #desk-icons,
  body[data-unrest] #backdrop,
  body[data-unrest] .titlebar,
  .lv-sanctum.wild .sig-main,
  .lv-sanctum.wild .sig-back,
  .lv-sanctum.wild::after,
  .lv-rite .cell { animation: none; }

  /* The head still arrives, still comes apart and still reassembles wrong —
     that is the content. What it loses is the travel and the scatter: the
     parts cross-fade between the two arrangements instead of flying. */
  #finale-pal { transition: opacity 1100ms linear; transform: scale(3); }
  #finale.arrived #finale-pal,
  #finale.growing #finale-pal,
  #finale.holding #finale-pal { transform: scale(3); transition: opacity 1100ms linear; }
  #finale-pal.reforming .eye.l,
  #finale-pal.reforming .eye.r,
  #finale-pal.reforming .mouth,
  #finale-pal.reforming .cheek.l,
  #finale-pal.reforming .cheek.r,
  #finale-pal.reforming .antenna,
  #finale-pal.reforming .antenna::after {
    animation-duration: 4600ms;
    animation-timing-function: steps(2, end);
  }
  #finale.shutdown #finale-inner { animation-duration: 1000ms; }
}

/* a cascade of windows — only the front one is whole, the rest are still naming
   themselves. Offsets are larger than a title bar so every name stays readable. */
.lv-cascade {
  position: relative;
  width: min(348px, 100%);
  height: 262px;
  margin: 6px auto;
}
.lv-cascade .cwin {
  position: absolute;
  left: calc(var(--i) * 28px);
  top: calc(var(--i) * 34px);
  z-index: var(--i);
  width: 258px;
  height: 152px;
  background: var(--face);
  border: var(--border-w) var(--border-style) var(--edge);
  box-shadow: var(--bevel-out), var(--win-shadow);
}
.lv-cascade .cbar {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 6px;
  background: var(--accent);
  color: var(--accent-ink);
  border-bottom: var(--border-w) var(--border-style) var(--edge);
  font-family: var(--font-display);
  font-size: 11px;
  letter-spacing: var(--tracking-wide);
}
.lv-cascade .cbar i {
  width: 10px;
  height: 10px;
  flex: none;
  background: var(--tbtn-bg);
  border: var(--border-w) var(--border-style) var(--tbtn-edge);
  box-shadow: var(--bevel-out);
}
.lv-cascade .cbar em { font-style: normal; }

/* level meters — read the bars against the printed scale. The scale is the whole
   point: it makes the level self-verifying, so there is no notation to know. */
.lv-meters {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 20px;
  background: var(--seg-bg);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  padding: 22px 26px;
  margin: 8px auto;
  width: max-content;
  max-width: 100%;
}
.lv-meters .axis {
  position: relative;
  width: 24px;
  height: 180px;
  flex: none;
  border-right: 1px solid var(--seg-off);
}
.lv-meters .axis em {
  position: absolute;
  right: 7px;
  bottom: calc(var(--y) * 20px);
  transform: translateY(50%);
  font-style: normal;
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--dim);
  font-variant-numeric: tabular-nums;
}
/* The ticks carry the scale now that the figures are gone, so they are longer
   and they use the same token as the ruled lines they line up with. */
.lv-meters .axis em::after {
  content: "";
  position: absolute;
  right: -9px;
  top: 50%;
  width: 9px;
  height: 1px;
  background: var(--dim);
}
.lv-meters .chans { display: flex; align-items: flex-end; gap: 24px; }
/* The ruled scale.
 *
 * `--dim` rather than `--seg-off`, and that is a fairness change rather than a
 * cosmetic one. The figures came off the axis so the level is now "count the
 * lines up to the top of the bar" — which means the lines have to be countable
 * at a glance in all eleven skins. `--seg-off` is the token for a segment that
 * is switched OFF; it is meant to disappear. `--dim` is the token for something
 * secondary that is still meant to be read. */
.lv-meters .chan {
  position: relative;
  width: 34px;
  height: 180px;
  display: flex;
  align-items: flex-end;
}

/* The ruling sits OVER the bar, not under it.
 *
 * As a background it was covered by the fill, so the only lines you could see
 * were the ones above the bar — which meant reading a channel was "count the
 * empty lines and subtract from ten". Backwards, and on the zero channel there
 * was nothing to subtract from anything.
 *
 * Over the top, you count the lines crossing the filled part and that IS the
 * digit. The first rule is at 20px rather than 0 so the baseline is not itself
 * a line to be counted, and every bar's top edge lands exactly on a rule — so
 * six lines means six, and an empty channel has none. */
.lv-meters .chan::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: repeating-linear-gradient(
    to top, transparent 0 19px, var(--dim) 19px 20px
  );
}
.lv-meters .chan b {
  width: 100%;
  height: calc(var(--v) * 20px);
  background: var(--seg-on);
  box-shadow: var(--seg-glow);
}

/* punched tape — currently unused. Parked in PUZZLE-SEEDS.md; not dead code. */
.lv-tape {
  align-self: center;
  background: var(--field);
  border: 3px solid var(--ink);
  padding: 20px 26px 20px 42px;
  display: flex;
  flex-direction: column;
  gap: 16px;
  position: relative;
  margin: 26px auto;
  width: max-content;
  max-width: 100%;
}
/* the sprocket track that runs the length of any real tape */
.lv-tape::before {
  content: "";
  position: absolute;
  left: 16px;
  top: 14px;
  bottom: 14px;
  width: 7px;
  background-image: radial-gradient(circle, var(--ink) 42%, transparent 46%);
  background-size: 7px 16px;
}
/* labelled channels, the way a real tape reader has them — and the thing that
   makes this a puzzle rather than a "do you happen to know binary" check */
.lv-tape .scale { display: flex; gap: 18px; margin-bottom: 2px; }
.lv-tape .scale em {
  width: 22px;
  text-align: center;
  font-style: normal;
  font-family: var(--font-display);
  font-size: 12px;
  color: var(--dim);
}
.lv-tape .row { display: flex; gap: 18px; }
/* A punched channel is a hole in the tape; an unpunched one is blank paper with
   a faint centre mark so the columns still line up. The first version drew both
   as circles and left it ambiguous which state was which. */
.lv-tape u {
  width: 22px;
  height: 22px;
  border-radius: 50%;
  display: block;
  position: relative;
}
.lv-tape u::after {
  content: "";
  position: absolute;
  inset: 9px;
  border-radius: 50%;
  background: var(--dim);
  opacity: 0.3;
}
.lv-tape u.p {
  background: var(--ink);
  box-shadow: inset -1px -2px 0 rgba(255, 255, 255, 0.22);
}
.lv-tape u.p::after { display: none; }

/* seven-segment panel — currently unused. Parked for act IV, where multi-step
   deduction belongs; it was too many steps for act I. See PUZZLE-SEEDS.md.
   Not dead code. */
.lv-seg {
  display: flex;
  gap: 20px;
  justify-content: center;
  background: var(--seg-bg);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  padding: 18px 22px;
}
.lv-seg .seg { position: relative; width: 34px; height: 60px; display: block; }
.lv-seg .s { position: absolute; background: var(--seg-off); }
.lv-seg .s.on { background: var(--seg-on); box-shadow: var(--seg-glow); }
.lv-seg .s.a, .lv-seg .s.d, .lv-seg .s.g { left: 6px; right: 6px; height: 5px; }
.lv-seg .s.a { top: 0; }
.lv-seg .s.g { top: 28px; }
.lv-seg .s.d { bottom: 0; }
.lv-seg .s.b, .lv-seg .s.c, .lv-seg .s.e, .lv-seg .s.f { width: 5px; }
.lv-seg .s.f { top: 5px; left: 0; height: 24px; }
.lv-seg .s.b { top: 5px; right: 0; height: 24px; }
.lv-seg .s.e { bottom: 5px; left: 0; height: 24px; }
.lv-seg .s.c { bottom: 5px; right: 0; height: 24px; }

/* ---------- the magnifier ----------
 * Tools → Magnifier. A circle of glass that follows the pointer and shows a 6x
 * clone of the desktop underneath it. Not a theme: it is an instrument the
 * system owns, so it looks the same in every skin bar the tokens it borrows. */

#lens {
  --lens-size: 240px;
  position: fixed;
  width: var(--lens-size);
  height: var(--lens-size);
  margin: calc(var(--lens-size) / -2) 0 0 calc(var(--lens-size) / -2);
  border-radius: 50%;
  border: 3px solid var(--edge);
  background: var(--desk);
  overflow: hidden;
  pointer-events: none;
  /* above the dialogs (100) and ARP (210), below the tube (300) — the glass is
     held against the screen, not painted on the inside of it */
  z-index: 280;
  box-shadow: 0 8px 22px rgba(0, 0, 0, 0.38);
}
#lens-view {
  position: absolute;
  top: 0;
  left: 0;
  transform-origin: 0 0;
}
/* the glass is out, so the desktop says so */
[data-tool="magnifier"] #desktop { cursor: crosshair; }

/* a menu item that is switched on. The classic mark, in the classic place. */
.mb-drop .mb-check { padding-left: 24px; position: relative; }
.mb-drop .mb-check.checked::before { content: "✓"; position: absolute; left: 8px; }

/* ---------- dialogs ---------- */

.modal {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  background: var(--scrim);
}
.modal[hidden] { display: none; }
.dialog { width: min(430px, 100%); }
.dialog.wide { width: min(560px, 100%); }

.about { flex-direction: row; gap: 16px; align-items: flex-start; }
.about p { margin: 0; }
.about-mark { font-size: 34px; color: var(--accent-mark); line-height: 1; }
.about-product { font-weight: 700; letter-spacing: var(--tracking-wide); }

.about-rows {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 3px 16px;
  margin: 10px 0;
  font-size: 12px;
}
.about-rows dt { color: var(--dim); }
.about-rows dd { margin: 0; font-variant-numeric: tabular-nums; letter-spacing: 0.06em; }

.scroller {
  max-height: 46vh;
  overflow-y: auto;
  border: var(--border-w) var(--border-style) var(--rule);
  background: var(--field);
}
.scroller .lv-files td { padding: 4px 10px; color: var(--field-ink); }

.hint-list { display: flex; flex-direction: column; gap: 10px; }
.hint-list p {
  margin: 0;
  padding: 9px 12px;
  background: var(--field);
  color: var(--field-ink);
  border-left: 3px solid var(--accent-mark);
  font-size: 12.5px;
}
.hint-list .tier {
  display: block;
  font-family: var(--font-display);
  font-size: 10px;
  letter-spacing: 0.14em;
  color: var(--dim);
  margin-bottom: 3px;
}

/* a row in the Trash is a button, so it can be picked up with a pointer or
   opened from the keyboard — the puzzle is noticing it, never dragging it */
.trash-file {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 4px;
  width: 100%;
  background: none;
  border: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: grab;
  touch-action: none;
}
.trash-file:active { cursor: grabbing; }
.trash-file:focus-visible { outline: var(--border-w) var(--border-style) var(--accent); }
.trash-note { margin-top: 10px; }
.trash-file .fglyph {
  width: 26px;
  height: 32px;
  flex: none;
  background: var(--field);
  border: var(--border-w) var(--border-style) var(--edge);
  box-shadow: var(--bevel-out);
}
.trash-file b { font-weight: 700; letter-spacing: var(--tracking); }

/* ---------- finish ---------- */

.finish { text-align: center; padding: 20px 0; display: flex; flex-direction: column; gap: 10px; }
.finish h2 {
  margin: 0;
  font-family: var(--font-display);
  font-size: 17px;
  letter-spacing: 0.16em;
}

/* ---------- overlays ---------- */

/* Per-theme texture: scanlines, graph paper, paper tooth. Sits above the
   dialogs so a CRT looks like a CRT all the way through, below the glitch. */
#overlay {
  position: fixed;
  inset: 0;
  z-index: 150;
  pointer-events: none;
  background-image: var(--overlay-img);
  background-size: var(--overlay-size);
  mix-blend-mode: var(--overlay-blend);
  opacity: var(--overlay-opacity);
}

@media (max-width: 520px) {
  #stage { padding: 18px 10px 80px; }
  .wbody { padding: 14px; }
  .lv-seg { gap: 12px; padding: 14px; }
}

/* ---------- TEMPORARY: the alt-games rating panel ----------
 * Only ever rendered under `?alt`, when a candidate has just been beaten. Dashed
 * borders throughout, so it cannot quietly become a feature. Delete with the
 * rest of the sandbox. */

.altrate { text-align: center; padding: 14px 0 6px; display: flex; flex-direction: column; gap: 10px; }
.altrate h2 {
  margin: 0;
  font-family: var(--font-display);
  font-size: 18px;
  letter-spacing: 0.16em;
}
.altrate-stars { display: flex; justify-content: center; gap: 6px; }
.altrate-stars .btn {
  font-size: 22px;
  line-height: 1;
  padding: 4px 10px;
  border-style: dashed;
  color: var(--dim);
}
.altrate-stars .btn.on { color: var(--accent-mark, var(--ink)); border-style: solid; }
.altrate-go { display: flex; justify-content: center; gap: 10px; margin-top: 6px; }
.altrate-go .btn { text-decoration: none; display: inline-block; }

/* ---------- the print copy ----------
 *
 * Not a convenience. Print is a second rendering of the same document, and
 * level 35 hides in the difference between the two — so this block is level
 * content, not housekeeping, and `.lv-print` must stay display:none on screen.
 *
 * Everything the machine draws comes off: the tube, the desktop, the chrome,
 * and the level's own screen copy. What is left on the page is the filed copy
 * and nothing else, which is what a form coming out of a 1993 printer would
 * actually look like.
 *
 * Every level that has no filed copy prints the sigil instead, alone on white.
 * That is atmosphere and it is also cover: if paper were blank everywhere except
 * one level, printing would announce itself the moment a player tried it twice.
 * This way the channel is familiar long before it is load-bearing, and the thing
 * that signs the page is the thing from the cold boot. It carries the rim glyphs
 * with it — the only place in the game they hold still on white. */

@media print {
  html, body {
    background: #fff !important;
    color: #000 !important;
    height: auto !important;
  }

  /* the set, the room it is in, and the furniture */
  #crt, #bezel, #overlay, #glitch, #intro, #poweron,
  #crt-toggle, #snd-toggle,
  #menubar, #desk-icons, #backdrop,
  #intrusion, #intrusion-tab,
  .modal { display: none !important; }

  /* the window reduced to its contents */
  #stage { padding: 0 !important; display: block !important; }
  #mainwin {
    width: auto !important;
    min-height: 0 !important;
    border: none !important;
    box-shadow: none !important;
    background: #fff !important;
  }
  .titlebar, #resp, #entry, .statusbar { display: none !important; }
  .wbody { padding: 0 !important; background: #fff !important; }

  /* the screen copy is not the filed copy */
  #level > *:not(.lv-print) { display: none !important; }
  .lv-print {
    display: block !important;
    font-family: var(--font-mono);
    font-size: 11pt;
    line-height: 2.1;
    letter-spacing: 0.12em;
    text-align: center;
    color: #000;
    padding-top: 8pt;
  }
  .lv-print p { margin: 0; }
  .pr-head { font-weight: 700; }
  .pr-label { font-size: 9pt; letter-spacing: 0.34em; margin-top: 26pt !important; }
  /* the whole reason the page was printed, at the size that deserves */
  .pr-code {
    font-size: 78pt;
    line-height: 1.05;
    letter-spacing: 0.1em;
    font-weight: 700;
    margin: 4pt 0 26pt !important;
  }
  .pr-foot { font-size: 9pt; letter-spacing: 0.28em; }

  /* ---- and on every level that has no filed copy of its own ---- */
  #printmark { display: block !important; text-align: center; padding-top: 22pt; }
  /* level 35 prints its form and nothing else; the mark stands down for it */
  body.filed-copy #printmark { display: none !important; }
  #printmark svg {
    width: 58%;
    max-width: 400pt;
    height: auto;
    stroke: #000;
    fill: none;
    stroke-linecap: round;
    stroke-linejoin: round;
  }
  #printmark text {
    fill: #000;
    stroke: none;
    font-family: ui-monospace, Menlo, monospace;
    font-size: 9.5px;
    letter-spacing: 0.16em;
  }
  #printmark text.inner { font-size: 8px; }
}

/* ══ PLAYTEST PASS — 18 Sep 2026 ═════════════════════════════════════════════
   Five small additions, each one a thing a player actually got stuck on. */

/* Level 8's caption, and only level 8's. Seven levels of identical captions
   teach the player to stop reading the title bar, and this is the level that
   has to untrain that in one screen. */
#wintitle.emph { font-weight: 700; letter-spacing: 0.06em; }

/* Level 13 prints the field it wants you to go and fill in. The value is a
   question mark rather than a blank so it reads as unknown rather than as
   empty — an empty field looks like the level forgot to write it. */
.lv-spec {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 4px 18px;
  margin: 18px 0 0;
  font-family: var(--font-mono);
  font-size: 12px;
}
.lv-spec dt { color: var(--dim); }
.lv-spec dd { margin: 0; color: var(--ink); }
.lv-spec dd.unknown { color: var(--dim); }

/* Level 16's recovered file. Line art in currentColor so it takes on whatever
   skin it opens in, which is also why it cannot be a picture file. */
.lv-cat {
  display: flex;
  justify-content: center;
  padding: 10px 0 4px;
  color: var(--ink);
}
.lv-cat svg { width: 150px; height: auto; }
.cat-tail {
  transform-origin: 84px 74px;
  animation: cat-tail 2.4s ease-in-out infinite;
}
@keyframes cat-tail {
  0%, 100% { transform: rotate(0deg); }
  50%      { transform: rotate(-13deg); }
}

/* Level 18's records are things you pick up, so they have to look like it.
   The grab cursor is the entire affordance — the drag already worked, and a
   playtester read "nothing here is draggable" straight off the pointer. */
[data-record] { cursor: grab; touch-action: none; }
[data-record]:active { cursor: grabbing; }
html.filedragging, html.filedragging * { cursor: grabbing !important; }

/* Level 20: File and Get Info brighten once a record is selected, so that
   selecting something visibly leads somewhere. */
#menubar [data-menu-btn].ready,
.mb-drop button.ready {
  color: var(--ink);
  font-weight: 700;
}

/* ── NOT FOUND ────────────────────────────────────────────────────────────
   A 404 for a path the machine can no longer spell. It is a lure: the player
   is looking at the page they are about to be asked to go and produce, with
   the part that would say where it came from eaten. */
.lv-404 {
  padding: 26px 22px 22px;
  background: var(--seg-bg);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  text-align: center;
}
.lv-404 .err-code {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(54px, 13vw, 96px);
  line-height: 0.9;
  letter-spacing: 0.02em;
  color: var(--ink);
}
.lv-404 .err-what {
  margin: 6px 0 20px;
  font-family: var(--font-mono);
  font-size: clamp(13px, 2.4vw, 18px);
  letter-spacing: 0.42em;
  color: var(--ink);
}
.lv-404 .err-path {
  margin: 0 0 14px;
  font-family: var(--font-mono);
  font-size: clamp(15px, 3vw, 22px);
  color: var(--dim);
  word-break: break-all;
}
.lv-404 .err-path b { font-weight: 400; }
/* The glyph runs flicker and jitter rather than re-spelling themselves.
   Three offset cycles, all stepped, so the path never holds still long enough
   to read as something worth typing into the address bar — but it also never
   resolves into a different string, because a path that changes is a path a
   player will sit and transcribe. */
.scram {
  display: inline-block;
  animation: scram 1.7s steps(1, end) infinite;
}
.scram:nth-of-type(2) { animation-duration: 2.3s; animation-delay: -0.6s; }
.scram:nth-of-type(3) { animation-duration: 1.1s; animation-delay: -0.3s; }
@keyframes scram {
  0%   { opacity: 1;    transform: translateY(0)    skewX(0deg); }
  25%  { opacity: 0.45; transform: translateY(-1px) skewX(-6deg); }
  50%  { opacity: 1;    transform: translateY(1px)  skewX(0deg); }
  75%  { opacity: 0.35; transform: translateY(0)    skewX(5deg); }
}
.lv-404 .err-note {
  margin: 0;
  font-size: 11px;
  letter-spacing: 0.08em;
  color: var(--dim);
  opacity: 0.8;
}

/* ── PLATES ───────────────────────────────────────────────────────────────
   Pictures that take a click and decline to do anything with it, and the
   operator's note that carries the real verb. */
.plate-btn {
  appearance: none;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  display: block;
  line-height: 0;
}
.plate-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.plate-btn:active img { transform: translateY(1px); }

.doc-note { max-width: 46ch; }
.doc-note p { margin: 0 0 12px; font-size: 13px; line-height: 1.7; }
.doc-note p:first-child {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.16em;
}
/* The four words, in the four plates' own colours. Nothing in the note says
   so — recognising the colour is the move. They used to be colour only, no
   weight, so they couldn't be found without the plates; Aaron's 28 Sep
   playthrough made them bold as well, because colour alone was too easy to
   read straight past. */
.doc-note b { font-weight: 700; }
.doc-note .c-square   { color: var(--plate-square); }
.doc-note .c-diamond  { color: var(--plate-diamond); }
.doc-note .c-circle   { color: var(--plate-circle); }
.doc-note .c-triangle { color: var(--plate-triangle); }

@media (prefers-reduced-motion: reduce) {
  .scram { animation: none; }
}

/* ── RECONCILIATION ───────────────────────────────────────────────────────
   Six words, and the screen says nothing else. Everything needed to act on it
   has been in the status bar since level one. */
.lv-huge {
  margin: 22px 0;
  font-family: var(--font-display);
  font-size: clamp(26px, 6.4vw, 54px);
  line-height: 1.12;
  letter-spacing: 0.04em;
  color: var(--ink);
  text-align: center;
  text-transform: uppercase;
}

/* ── NIGHT LOG — the rota ─────────────────────────────────────────────────
   A notice with five clauses on it and the means to satisfy them. */
.lv-roster { display: flex; gap: 22px; flex-wrap: wrap; align-items: flex-start; }
.ros-notice {
  flex: 1 1 260px;
  min-width: 0;
  padding: 16px 18px;
  background: var(--face-alt);
  border: var(--border-w) var(--border-style) var(--edge);
}
.ros-head {
  margin: 0 0 12px;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.22em;
  color: var(--dim);
}
.ros-notice ol { margin: 0; padding-left: 20px; }
.ros-notice li { margin-bottom: 9px; font-size: 12.5px; line-height: 1.6; }
/* The clause that was added later, and it is the one that changes the answer. */
.ros-pin { font-style: italic; }
.ros-foot {
  margin: 12px 0 0;
  font-size: 11px;
  color: var(--dim);
  border-top: 1px solid var(--edge);
  padding-top: 9px;
}

.ros-form { flex: 1 1 250px; min-width: 0; }
.ros-row { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.ros-row label {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.18em;
  color: var(--dim);
}
.ros-row select,
.ros-row input[type="time"] {
  font: inherit;
  font-size: 13px;
  padding: 5px 7px;
  background: var(--field);
  color: var(--field-ink);
  border: var(--border-w) var(--border-style) var(--field-edge);
}
.ros-cal {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 2px;
  margin-bottom: 14px;
  max-width: 260px;
}
.ros-cal .cal-h {
  text-align: center;
  font-family: var(--font-mono);
  font-size: 9px;
  color: var(--dim);
  padding-bottom: 3px;
}
.ros-cal .cal-d {
  appearance: none;
  aspect-ratio: 1;
  font: inherit;
  font-size: 11px;
  background: var(--face);
  color: var(--ink);
  border: 1px solid var(--edge);
  cursor: pointer;
  padding: 0;
}
.ros-cal .cal-d:hover { background: var(--face-alt); }
.ros-cal .cal-d.on { background: var(--ink); color: var(--face); }
.lv-log.hit { color: var(--ink); }

/* ── FIVE PANES ───────────────────────────────────────────────────────────
   Five apertures onto one sheet. Each shows a fixed slice; the player brings
   them together until the figures complete. Nothing is checked — the level is
   read, not solved, and the bar at the bottom is the only judge. */
.lv-panes {
  position: relative;
  height: 300px;
  margin: 10px 0 4px;
  background: var(--seg-bg);
  border: var(--border-w) var(--border-style) var(--field-edge);
  box-shadow: var(--bevel-in);
  overflow: hidden;
  touch-action: none;
}
.lv-panes .pane {
  position: absolute;
  left: var(--x);
  top: var(--y);
  width: 88px;
  background: var(--face);
  border: var(--border-w) var(--border-style) var(--edge);
  box-shadow: var(--bevel-out);
  cursor: grab;
  user-select: none;
}
.lv-panes .pane.held { cursor: grabbing; box-shadow: var(--bevel-out), 0 10px 24px rgba(0,0,0,0.35); }
.lv-panes .pane-bar {
  display: block;
  padding: 3px 6px;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.18em;
  color: var(--dim);
  background: var(--face-alt);
  border-bottom: 1px solid var(--edge);
}
/* The aperture. 88px wide onto a 440px strip, so each pane shows exactly one
   fifth of it and every cut lands mid-figure — a slice is unreadable alone and
   a digit only resolves when its two halves are brought back together. */
.lv-panes .pane-view {
  display: block;
  width: 88px;
  height: 92px;
  overflow: hidden;
  position: relative;
}
.lv-panes .pane-view b {
  position: absolute;
  top: 0;
  left: calc(var(--o) * -88px);
  width: 440px;
  height: 92px;
  display: block;
  color: var(--ink);
}
/* Each figure is centred on a CUT rather than inside a pane.
 *
 * First build let the four figures sit centred in the 440px strip, which put
 * all of them in the middle three panes — the outer two showed nothing at all,
 * so two of the five were decoys and the cuts fell between figures instead of
 * through them. That is a sorting exercise, not the level.
 *
 * The pane edges are at 88, 176, 264 and 352, and there are exactly four
 * figures, so each one sits on one edge. Every pane carries half of something,
 * nothing is legible alone, and a figure only resolves when the two panes
 * holding its halves are brought together — which is the whole idea. */
.lv-panes .pane-view b i {
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
  font-family: var(--font-display);
  font-size: 84px;
  font-style: normal;
  line-height: 1;
}
.lv-panes .pane-view b i:nth-child(1) { left: 88px; }
.lv-panes .pane-view b i:nth-child(2) { left: 176px; }
.lv-panes .pane-view b i:nth-child(3) { left: 264px; }
.lv-panes .pane-view b i:nth-child(4) { left: 352px; }
