/* TVG3 — Dark IDE layout with panels */

:root {
  --bg: #0a0a0f;
  --bg2: #0e0e16;
  --fg: #c8ccd0;
  --fg-dim: #666;
  --grid: #1a1a2e;
  --accent: #4a9eff;
  --accent2: #ff6b4a;
  --scalar: #4aff8b;
  --vector-color: #ffcc4a;
  --matrix-color: #c74aff;
  --contra-warm: #ff6644;
  --cov-cool: #4488ff;
  --higher-rank: #ff4a3a;
  --stroke: #ffffff;
  --toolbar-bg: #12121a;
  --toolbar-border: #2a2a3e;
  --hover: #1e1e30;
  --panel-bg: #0c0c14;
  --panel-w: 180px;
  --panel-l-w: 180px;
  --panel-r-w: 180px;
  --gutter-w: 6px;
  --gutter-h: 6px;
  --toolbar-h: auto;
  --bottom-h: 140px;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

html, body {
  width: 100%; height: 100%;
  overflow: hidden;
  background: var(--bg);
  color: var(--fg);
  font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
  font-size: 12px;
  /* iOS Safari hardening:
     -webkit-touch-callout: prevent the long-press magnifier / share menu
     user-select: prevent the long-press text-selection lasso
     -webkit-text-size-adjust: prevent iOS auto-resize on rotate
     touch-action: disable browser pinch-zoom (we own all gestures) */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-text-size-adjust: 100%;
  touch-action: none;
  overscroll-behavior: contain;
}

/* === Capability gates ===
 *
 * The app exposes two capabilities a tab may or may not hold:
 *   • can-mutate  — write access to canonical state (set by ?k=…
 *                   master key matching the file's allow-list).
 *                   Default for fresh tabs without a key: NO.
 *   • can-edit    — mass-edit-mode on a writable file (state.editMode
 *                   AND the file is a user file, not a demo).
 *                   Strictly a subset of can-mutate.
 *
 * Both classes are set on <body> by syncEditUI() in tvg3.js, refreshed
 * after every state push.  This section is the central registry: every
 * UI affordance that only makes sense for a mutation-capable tab gates
 * itself here, declaratively.  Chief 2026-05-26: "unprivileged tabs see
 * things like raster drag handles on click, which makes no sense" — gate
 * the affordance, not just the action.  An unprivileged tab that sees
 * the handle, drags it, gets a server rejection is bad UX; not seeing
 * the handle at all is the right physical reading.
 *
 * To gate a new affordance:
 *   body:not(.can-mutate) #my-affordance { display: none; }
 * or
 *   body:not(.can-edit)   #my-affordance { display: none; }
 *
 * Companion JS guard: modules doing expensive per-frame work for these
 * affordances (selection-handles' projection loop, mass-edit halos)
 * short-circuit on window.tvg3.canMutate()/canEdit() — saves cycles in
 * addition to hiding pixels.
 */
body:not(.can-mutate) #tvg3-selection-handles {
  display: none;
}

/* === App grid === */
#app {
  display: grid;
  grid-template-rows: var(--toolbar-h) 1fr var(--gutter-h) var(--bottom-h);
  width: 100%; height: 100%;
}

/* === Hamburger + drawer ===
   One menu surface for every mode.  The hamburger floats top-RIGHT,
   the drawer slides in from the right; the only thing that changes
   between mobile-ui and desktop-ui is whether the side/bottom panels
   are visible underneath.  Right-side placement puts the menu where
   the thumb naturally rests on a phone/tablet held one-handed. */
#btn-toolbar-stow {
  /* Flows inside #viewport-top-seed-row (moved there by JS on init).
     Aligned with the seed strip's baseline via flex align-items.
     `margin-left: auto` pins the hamburger to the right edge so it
     stays in place when the seed strip is hidden — convention: drawer
     toggles live in the upper-right corner.  Hide the hamburger and
     the seed strip naturally takes its vacated space. */
  flex: 0 0 auto;
  margin-left: auto;
  z-index: 50;
  min-width: 44px;
  min-height: 44px;
  padding: 10px 14px;
  font-size: 20px;
  background: rgba(18, 18, 26, 0.92);
  color: var(--fg);
  border: 1px solid var(--toolbar-border);
  border-radius: 6px;
  cursor: pointer;
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
  align-self: stretch;          /* match row height */
}
#btn-toolbar-stow:hover { background: var(--hover); }
/* ON-AIR — subtle red glow behind the hamburger while broadcasting.
   Pulses gently so peripheral vision picks it up without it shouting.
   The body.on-air class is toggled by renderBroadcastUI. */
body.on-air #btn-toolbar-stow {
  border-color: rgba(255, 70, 70, 0.55);
  box-shadow: 0 0 12px 2px rgba(255, 60, 60, 0.45),
              0 0 28px 6px rgba(255, 40, 40, 0.20);
  animation: on-air-pulse 2.4s ease-in-out infinite;
}
@keyframes on-air-pulse {
  0%, 100% { box-shadow: 0 0 10px 2px rgba(255, 60, 60, 0.40),
                          0 0 24px 5px rgba(255, 40, 40, 0.16); }
  50%      { box-shadow: 0 0 16px 3px rgba(255, 70, 70, 0.60),
                          0 0 32px 8px rgba(255, 40, 40, 0.28); }
}

/* Agent active — subtle accent (teal) glow behind the hamburger
   while an instruct-agent / raster-agent run is in flight.  Same
   peripheral-cue pattern as ON-AIR; different colour so the chief
   can tell at a glance whether they're broadcasting (red) or
   running an agent (teal).  body.agent-active is toggled by the
   agent-log message handler in tvg3.js based on msg.status. */
body.agent-active #btn-toolbar-stow {
  border-color: rgba(74, 158, 255, 0.55);
  box-shadow: 0 0 12px 2px rgba(74, 158, 255, 0.45),
              0 0 28px 6px rgba(46, 212, 201, 0.20);
  animation: agent-active-pulse 2.4s ease-in-out infinite;
}
@keyframes agent-active-pulse {
  0%, 100% { box-shadow: 0 0 10px 2px rgba(74, 158, 255, 0.40),
                          0 0 24px 5px rgba(46, 212, 201, 0.16); }
  50%      { box-shadow: 0 0 16px 3px rgba(74, 158, 255, 0.60),
                          0 0 32px 8px rgba(46, 212, 201, 0.28); }
}

#toolbar {
  position: fixed;
  top: 0;
  right: 0;
  width: min(360px, 75vw);
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  padding: 0;
  overflow-y: auto;
  overflow-x: hidden;
  background: var(--toolbar-bg);
  border-left: 1px solid var(--toolbar-border);
  transform: translateX(100%);
  transition: transform 0.2s ease-out;
  z-index: 40;
  box-shadow: -2px 0 12px rgba(0, 0, 0, 0.4);
}
body.drawer-open #toolbar {
  transform: translateX(0);
}
body.drawer-open::after {
  content: '';
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.45);
  z-index: 35;
}
.toolbar-break { display: none; }

.drawer-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 6px 12px;
  border-bottom: 1px solid var(--toolbar-border);
  background: rgba(8, 8, 14, 0.7);
  width: 100%;
  flex-shrink: 0;
}
/* Two links rendered as ONE continuous URL (host + path), with no
   visible gap, both hoverable separately.  Same size, same colour,
   same font — only the hover state distinguishes which segment is
   under the pointer. */
.drawer-title-group {
  display: inline-flex;
  align-items: baseline;
  gap: 0;
}
.drawer-title {
  font-size: 12px;
  color: var(--accent);
  text-transform: none;
  letter-spacing: 0.04em;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  text-decoration: none;
  padding: 6px 0;
}
.drawer-title-group .drawer-title + .drawer-title { padding-left: 0; }
a.drawer-title:hover {
  color: #fff;
  text-decoration: underline;
}
/* Close button uses the SAME hamburger glyph and the SAME size as
   the floating opener (#btn-toolbar-stow), so the menu icon stays
   in the same upper-right corner whether the drawer is open or shut.
   Emphasized state — accent-coloured border + glyph — signals "menu
   active". */
.drawer-close {
  min-width: 30px;
  min-height: 30px;
  padding: 6px 8px;
  background: rgba(18, 18, 26, 0.92);
  color: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 5px;
  cursor: pointer;
  box-shadow: 0 0 10px rgba(46, 212, 201, 0.30);
}
.drawer-close:hover { background: rgba(46, 212, 201, 0.18); }

/* Drawer layout: the big option groups (file, rank, reduction,
   routing, projection) each become a STRETCHED SPINNER row — label
   on top, [◀ value ▶] below — and distribute the available vertical
   space evenly via flex: 1.  Tool rows (undo/redo/save, 6DOF, etc.)
   stay compact at the bottom.  Touch targets ≥ 44×44 px (iOS HIG). */
#toolbar .toolbar-group {
  display: flex;
  align-items: center;
  justify-content: flex-end;   /* right-align rows in the drawer */
  flex-wrap: wrap;
  gap: 3px 4px;
  padding: 2px 10px;
  border-bottom: 1px solid rgba(42, 42, 62, 0.28);
  width: 100%;
  flex: 0 0 auto;
}
/* Restore the [hidden] attribute's normal effect — the explicit
   `display: flex` above otherwise wins over the UA `[hidden] {
   display: none }` rule, leaving JS-driven `el.hidden = true` calls
   inert.  This was the smoking gun behind the "Following / LEAVE
   pill shows when nobody is broadcasting" bug: renderBroadcastUI
   was setting ownerGroup.hidden=true when no broadcast was active,
   but the pill stayed on screen because the CSS rule above
   overrode the hide. */
#toolbar .toolbar-group[hidden] { display: none; }
/* Per-row override — center alignment for highlighted rows like the
   Broadcast headline action. */
#toolbar .toolbar-group.toolbar-group--centered {
  justify-content: center;
}

/* Hidden section — fully removed from layout.  The underlying
   <select> stays in the DOM (JS reads / writes `select.value` for
   reduction state), but no UI surface is rendered until we bring
   the section back. */
.toolbar-section--hidden {
  display: none;
}

/* Broadcast — drawer mirror of the bottom-of-viewport broadcast pill.
   At rest the pill is muted (an OPTION, not an active state); the
   inner "GO LIVE" chip carries the call-to-action.  When broadcasting
   the whole pill turns teal with a glow, matching the bottom pill's
   .live look so the drawer + viewport speak the same visual language. */
#btn-broadcast.bb-pill {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 4px 4px 4px 12px;
  background: rgba(12, 12, 20, 0.94);
  border: 1px solid var(--toolbar-border);
  border-radius: 999px;
  color: var(--fg-dim);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.04em;
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s, box-shadow 0.15s;
}
#btn-broadcast.bb-pill:hover {
  color: #2ed4c9;
  border-color: #2ed4c9;
}
#btn-broadcast .bb-pill__label {
  /* The plain noun — passive, low contrast. */
}
#btn-broadcast .bb-pill__action {
  border: 1px solid currentColor;
  border-radius: 999px;
  padding: 3px 10px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: inherit;
  background: transparent;
  transition: background 0.15s;
}
#btn-broadcast.bb-pill:hover .bb-pill__action {
  background: rgba(46, 212, 201, 0.18);
}
/* Live state — fill the action chip + glow the pill outline. */
#btn-broadcast.bb-pill.live {
  color: #2ed4c9;
  border-color: #2ed4c9;
  box-shadow: 0 0 18px rgba(46, 212, 201, 0.35);
}
#btn-broadcast.bb-pill.live .bb-pill__action {
  background: #2ed4c9;
  color: #0a0a0f;
}
/* Snapshot button — sits next to the broadcast pill.  Camera-icon
   pill that captures the current canvas to screenshots/.  Same
   visual family as bb-pill (rounded, dark fill, dim text) but a
   warm-amber accent on hover/fire so it doesn't read as a
   broadcast-state indicator. */
#btn-snapshot.bb-snap {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-left: 6px;
  padding: 4px 12px;
  background: rgba(12, 12, 20, 0.94);
  border: 1px solid var(--toolbar-border);
  border-radius: 999px;
  color: var(--fg-dim);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.04em;
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s, box-shadow 0.15s;
}
#btn-snapshot.bb-snap:hover {
  color: #ffb84d;
  border-color: #ffb84d;
}
#btn-snapshot .bb-snap__icon {
  font-size: 13px;
  line-height: 1;
}
/* Firing flash — brief amber glow to ACK that the capture went out. */
#btn-snapshot.bb-snap--firing {
  color: #ffb84d;
  border-color: #ffb84d;
  background: rgba(255, 184, 77, 0.18);
  box-shadow: 0 0 16px rgba(255, 184, 77, 0.45);
}

/* QoS indicator — three colored counts next to the broadcast pill,
   read in order: source · screen · audience.  The number is the
   count of clients in that ring; color encodes health (green=good,
   amber=stalled, red=torn — worst client wins).  Source is always 1
   when broadcasting; screen is small (1, occasionally 2 with a
   backup); audience is the long tail.  Relative magnitude tells the
   reader which is which without distinct shapes. */
.qos-indicator {
  display: inline-flex;
  align-items: baseline;
  gap: 10px;
  margin-left: 10px;
  font-size: 14px;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  user-select: none;
}
.qos-pip {
  color: #6e6e76;             /* default = inactive / unknown */
  transition: color 120ms ease;
}
.qos-pip.qos-good     { color: #2ed4c9; }
.qos-pip.qos-stalled  { color: #f7c948; }
.qos-pip.qos-torn     { color: #ff5d6c; text-shadow: 0 0 6px rgba(255, 93, 108, 0.55); }
/* Picker groups — each hosts an iOS-style slot-machine wheel.  Label
   above; scroll-snap wheel below.  The underlying <select> is kept
   in the DOM as the source of truth (for existing change handlers)
   but visually hidden.
   ─── sizing ───
   Default: compact — picker uses its natural height (which is driven
   by the item count, see .picker sizing below).  The File picker is
   marked .picker-group--grow and takes ALL remaining vertical space
   via flex: 1 1 0, so it shows as many file rows as fit. */
#toolbar .toolbar-group.picker-group {
  flex: 0 0 auto;
  flex-direction: column;
  flex-wrap: nowrap;                    /* override base .toolbar-group wrap */
  align-items: stretch;
  justify-content: flex-start;
  gap: 6px;
}
#toolbar .toolbar-group.picker-group.picker-group--grow {
  flex: 1 1 0;
  min-height: 176px;                     /* ≥ 3 rows even when drawer is short */
}
#toolbar .toolbar-label {
  display: inline-block;
  min-width: 80px;
  color: var(--fg-dim);
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  font-weight: 500;
}
#toolbar .toolbar-group.picker-group .toolbar-label {
  min-width: 0;
  text-align: center;
  font-size: 11px;
  letter-spacing: 0.08em;
}
#toolbar select {
  flex: 1 1 auto;
  min-height: 30px;
  padding: 4px 8px;
  font-size: 12px;
  border-radius: 4px;
}
#toolbar button {
  min-height: 28px;
  min-width: 0;
  padding: 3px 6px;
  font-size: 12px;
  border-radius: 4px;
  gap: 5px;
}
/* Hidden source-of-truth <select> behind every picker/chip group,
   plus bare state-only selects that aren't shown as wheels (rank,
   projection).  Force flex: 0 so they DON'T steal grow space from
   picker-group--grow — <select> defaults to `1 0 auto` in Chrome. */
#toolbar select.picker-source,
#toolbar select.hidden-select {
  display: none;
  flex: 0 0 auto;
}
/* Chip group — tight segmented-control row(s) for selects with a
   small option count.  One tap, no scrolling.  Chips flex-wrap so
   a 6-option list can break onto two rows if the drawer is narrow. */
#toolbar .toolbar-group.chip-group {
  flex: 0 0 auto;
  flex-direction: column;
  flex-wrap: nowrap;
  align-items: stretch;
  justify-content: flex-start;
  gap: 6px;
}
/* Poincaré-2 parameter panel — a small stack of labelled sliders
   that only appears when that reduction is active.  Lives between
   the Reduction chips and the Hyperbolic toggle in the drawer.
   Persistence: localStorage + server's *poincare2-* defvars. */
#poincare2-params.poincare2-params {
  display: none;
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  padding: 8px 14px 10px;
}
body.poincare2-active #poincare2-params.poincare2-params {
  display: flex;
}
#spacecraft-params.spacecraft-params {
  display: none;
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  padding: 8px 14px 10px;
}
body.spacecraft-active #spacecraft-params.spacecraft-params {
  display: flex;
}
#spacecraft-params .p2-row {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 28px;
}
#poincare2-params .p2-row {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 28px;
}
#poincare2-params .p2-label {
  flex: 0 0 56px;
  font-size: 11px;
  color: var(--fg-dim);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
#poincare2-params input[type="range"] {
  flex: 1 1 auto;
  height: 4px;
  min-width: 0;
  appearance: none;
  -webkit-appearance: none;
  background: var(--toolbar-border);
  border-radius: 2px;
  outline: none;
}
#poincare2-params input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  width: 16px; height: 16px;
  border-radius: 50%;
  background: var(--accent);
  cursor: pointer;
}
#poincare2-params .p2-val {
  flex: 0 0 44px;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  color: var(--accent);
  text-align: right;
}
#poincare2-params .p2-reset {
  align-self: flex-end;
  margin-top: 2px;
  padding: 3px 10px;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-dim);
  background: transparent;
  border: 1px solid var(--toolbar-border);
  border-radius: 3px;
  cursor: pointer;
}
#poincare2-params .p2-reset:hover {
  color: var(--accent);
  border-color: var(--accent);
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  width: 100%;
}
.chip {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 40px;
  padding: 6px 10px;
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 1.1;
  text-align: center;
  color: var(--fg);
  background: rgba(74, 158, 255, 0.06);
  border: 1px solid var(--toolbar-border);
  border-radius: 6px;
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.chip:hover, .chip:active {
  background: rgba(74, 158, 255, 0.18);
  border-color: var(--accent);
}
.chip.active {
  background: var(--accent);
  color: var(--bg);
  border-color: var(--accent);
}

/* List-view picker — tap-to-select, no dead-space padding.
   ─────────────────────────────────────────────────────────────
   The old wheel centred the active row in the middle of the
   viewport, which meant half the picker was empty padding when the
   selection was near the top or bottom.  The list view instead
   places items starting at the top and highlights the active row
   with a full accent fill — no fixed centre band, no padding, every
   pixel shows a file.  Scroll-snap keeps the row boundaries crisp
   on touch. */
.picker {
  position: relative;
  flex: 1 1 auto;                        /* fill the picker-group */
  min-height: 132px;                     /* ≥ 3 rows */
  overflow: hidden;
  border-radius: 8px;
  background: rgba(0, 0, 0, 0.28);
  border: 1px solid var(--toolbar-border);
}
.picker-scroll {
  height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  scroll-snap-type: y proximity;
  scrollbar-width: none;
  -ms-overflow-style: none;
  box-sizing: border-box;
  -webkit-overflow-scrolling: touch;
}
.picker-scroll::-webkit-scrollbar { width: 0; height: 0; display: none; }
.picker-item {
  height: 44px;
  flex: 0 0 44px;
  scroll-snap-align: start;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 8px;
  font-size: 15px;
  font-weight: 500;
  color: var(--fg-dim);
  padding: 0 10px;
  white-space: nowrap;
  overflow: hidden;
  cursor: pointer;
  transition: background 0.12s, color 0.12s;
  user-select: none;
  -webkit-user-select: none;
}
.picker-item:hover {
  background: rgba(74, 158, 255, 0.10);
  color: var(--fg);
}
.picker-item.active {
  background: var(--accent);
  color: var(--bg);
  font-weight: 700;
}
/* Kind badge — tiny pill before the name that distinguishes
   curated demo files from the user's own saves.  Colour-coded:
   demo = gold (canonical, don't edit casually); user = teal
   (yours, editable, deletable). */
.picker-item__badge {
  flex: 0 0 auto;
  min-width: 38px;
  text-align: center;
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  padding: 2px 6px;
  border-radius: 3px;
  border: 1px solid transparent;
}
/* Composable tag pills — one independent pill per tag in the
   file's tag set.  Each kind has its own colour so the chief
   can scan the picker by colour and pick out a section at a
   glance.  Visibility (published) reads as a DIFFERENT KIND of
   chip: filled with accent and prefixed by a dot, so it stands
   out against the kind chips as a state-flag, not another label. */
.picker-item__badges {
  flex: 0 0 auto;
  display: inline-flex;
  gap: 4px;
  align-items: center;
}
.picker-item__badge--user {
  /* Teal — chief's accent, signals "user-owned, editable". */
  color: #2ed4c9;
  border-color: rgba(46, 212, 201, 0.45);
  background: rgba(46, 212, 201, 0.10);
}
.picker-item__badge--demo {
  /* Warm gold — signals "library/template content". */
  color: var(--vector-color);
  border-color: rgba(255, 204, 74, 0.45);
  background: rgba(255, 204, 74, 0.08);
}
.picker-item__badge--published {
  /* Same outline-on-tint shape as USER and DEMO — just red, matching
     the canvas's red Saturn ring on published cells.  The chief
     reads the colour as the signal; the shape stays consistent
     with the other kind chips. */
  color: #ff5252;
  border-color: rgba(255, 82, 82, 0.45);
  background: rgba(255, 82, 82, 0.10);
}
.picker-item__badge--unlisted {
  /* Muted grey + italic.  Reads as "in the system but not surfaced
     in lists" — orthogonal to PUBLISHED (a file can be both
     PUBLISHED and UNLISTED: spectator-visible content via deep
     link, but not in the picker dropdown).  Italic mirrors the
     row-label italic that the picker uses for unlisted rows. */
  color: #888;
  border-color: rgba(136, 136, 136, 0.35);
  background: rgba(136, 136, 136, 0.08);
  font-style: italic;
}
.picker-item.active .picker-item__badge {
  /* Active row is already filled with accent — drop the badge bg
     so it reads as a subtle border rather than a clashing pill. */
  background: transparent;
  color: var(--bg);
  border-color: rgba(12, 12, 20, 0.45);
}
/* Hidden files: master sees them dimmed + italic so the row reads
   as "still here, not public".  Spectators never see the row at
   all — the server filters those names out before send. */
.picker-item--unlisted .picker-item__label {
  font-style: italic;
  opacity: 0.55;
}
.picker-item__label {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Trash button — user rows only.  Large enough to thumb, clearly
   separated from the row tap-target.  Clicking stops propagation
   so the row itself doesn't also get selected as a side effect. */
.picker-item__del {
  flex: 0 0 auto;
  width: 44px;
  height: 34px;
  margin: 0 -2px 0 4px;
  padding: 0;
  font-size: 16px;
  line-height: 1;
  color: var(--accent2);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 5px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0.55;
  transition: opacity 0.12s, background 0.12s, border-color 0.12s;
}
.picker-item:hover .picker-item__del,
.picker-item.active .picker-item__del {
  opacity: 1;
}
.picker-item__del:hover,
.picker-item__del:active {
  background: rgba(255, 107, 74, 0.18);
  border-color: var(--accent2);
}
.picker-item.active .picker-item__del {
  color: var(--bg);
}

/* Per-row "more actions" ellipsis — single discoverable button on
   every user-file row.  Click opens #file-row-menu (Rename / Delete /
   Save / Duplicate).  Mirrors the trash button's geometry. */
.picker-item__more {
  flex: 0 0 auto;
  width: 34px;
  height: 34px;
  margin: 0 -2px 0 4px;
  padding: 0;
  font-size: 18px;
  line-height: 1;
  color: var(--fg);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 5px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0.7;
  transition: opacity 0.12s, background 0.12s, border-color 0.12s;
}
.picker-item:hover .picker-item__more,
.picker-item.active .picker-item__more {
  opacity: 1;
}
.picker-item__more:hover,
.picker-item__more:active {
  background: rgba(74, 158, 255, 0.18);
  border-color: var(--accent);
}
.picker-item.active .picker-item__more {
  color: var(--bg);
}

/* Per-row actions popover (Rename / Delete / Save / Duplicate) */
#file-row-menu.popover {
  position: fixed;
  z-index: 10001;
  min-width: 160px;
  background: rgba(12, 12, 20, 0.96);
  border: 1px solid var(--accent);
  border-radius: 4px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
  padding: 4px 0;
  backdrop-filter: blur(4px);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 12px;
}
#file-row-menu[hidden] { display: none; }
#file-row-menu .fr-item {
  padding: 7px 14px;
  cursor: pointer;
  color: var(--fg);
  white-space: nowrap;
  user-select: none;
}
#file-row-menu .fr-item:hover {
  background: rgba(74, 158, 255, 0.18);
  color: var(--accent);
}
#file-row-menu .fr-item--danger { color: var(--accent2); }
#file-row-menu .fr-item--danger:hover {
  background: rgba(255, 107, 74, 0.18);
  color: #ffbfa6;
}
/* Armed (first-click) state for the in-place Delete confirm — clearly
   distinguishable from the unarmed look so the user can tell the
   action is now hot. */
#file-row-menu .fr-item--armed {
  background: rgba(255, 107, 74, 0.32);
  color: #ffffff;
  font-weight: 600;
}
#toolbar .btn-text { display: inline; }
#toolbar .toolbar-status {
  padding: 12px 14px;
  color: var(--fg-dim);
  font-size: 13px;
}
/* Fullscreen button — accent-tinted so it reads as the primary
   content-first action.  Stays visible when the toolbar is stowed
   so the user can always toggle fullscreen. */
.toolbar-fullscreen {
  background: rgba(74, 158, 255, 0.12) !important;
  border-color: rgba(74, 158, 255, 0.5) !important;
}
.toolbar-fullscreen:hover {
  background: rgba(74, 158, 255, 0.25) !important;
}
/* Fullscreen relies on the browser's native Element.requestFullscreen
   to hide chrome and fill the display — no app-side overlay, no exit
   chip.  The browser's own affordance (Esc / F11) handles exit.  The
   exit-fullscreen button element in index.html is kept in the DOM as
   a no-op; CSS hides it permanently. */
#btn-exit-fullscreen { display: none !important; }

/* Resize mode — entered via context menu "Resize…".  Cursor + a
   small bottom-of-viewport hint signal that the next drag scales
   the selected raster.  No idle UI — the user explicitly invokes
   the mode. */
body.resize-mode { cursor: nwse-resize; }
body.resize-mode #scene { cursor: nwse-resize; }

/* Draw mode — stylus illustration.  Crosshair cursor on canvas so
   the drawing intent is visible; the toolbar button takes the
   accent fill of other active toggles. */
body.draw-mode #scene canvas,
body.draw-mode #scene { cursor: crosshair; }
#btn-draw.active {
  background: rgba(74, 158, 255, 0.18);
  border-color: #4a9eff;
  color: #4a9eff;
}
body.resize-mode::after {
  content: 'Resize: drag to scale · Esc to cancel';
  position: fixed;
  left: 50%;
  bottom: 18px;
  transform: translateX(-50%);
  z-index: 10000;
  padding: 6px 14px;
  background: rgba(20, 24, 32, 0.92);
  color: var(--accent, #4a9eff);
  border: 1px solid var(--accent, #4a9eff);
  border-radius: 4px;
  font: 12px 'JetBrains Mono', 'Fira Code', monospace;
  pointer-events: none;
  letter-spacing: 0.04em;
}

.toolbar-break ~ * { margin-left: auto; }
.toolbar-break ~ * ~ * { margin-left: 0; }

#toolbar select { min-width: 0; }

#toolbar button, #toolbar select {
  background: transparent;
  color: var(--fg);
  border: 1px solid transparent;
  cursor: pointer;
  font-family: inherit;
  transition: all 0.12s;
  flex-shrink: 0;
}

#toolbar button {
  display: inline-flex;
  align-items: center;
  line-height: 1;
}

/* Icon system — every .btn-icon is a 16x16 mask painted by
   background-color; the SVG silhouette comes from the per-icon
   .icon-<name> class.  No emoji, no platform-font dependency,
   each icon carries its own hue. */
.btn-icon {
  display: inline-block;
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
  background-color: currentColor;
  -webkit-mask-size: contain;
  -webkit-mask-position: center;
  -webkit-mask-repeat: no-repeat;
  mask-size: contain;
  mask-position: center;
  mask-repeat: no-repeat;
  font-size: 0;
  line-height: 0;
  vertical-align: middle;
}
/* Active-button + accent-pill states adopt the foreground color so
   icons read correctly against an accent fill. */
#toolbar button.active .btn-icon { background-color: var(--bg); }
.bb-pill .btn-icon { background-color: currentColor; }

#toolbar .btn-text {
  font-size: 11px;
  letter-spacing: 0.3px;
}

#toolbar button:hover, #toolbar select:hover {
  background: var(--hover);
  border-color: var(--toolbar-border);
}

#toolbar button.active {
  background: var(--accent);
  color: var(--bg);
  border-color: var(--accent);
}

/* Save-button transient feedback.  The button toggles through
   .saving (in-flight, dimmed) → .saved (success, green flash) →
   default.  Without this the user clicked a button that did
   something silently and had no visible reason to trust it. */
#btn-save.saving {
  opacity: 0.55;
  pointer-events: none;
}
#btn-save.saved {
  background: rgba(80, 200, 120, 0.85);
  color: #fff;
  border-color: rgba(80, 200, 120, 1);
  transition: background 0.12s, color 0.12s, border-color 0.12s;
}

#toolbar select {
  -webkit-appearance: none;
  appearance: none;
  padding-right: 14px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%23c8ccd0'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 4px center;
}

#toolbar select option {
  background: var(--toolbar-bg);
  color: var(--fg);
}

.toolbar-group { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
/* btn-pair: wrap a primary button + its dropdown chevron so the pair
   stays together on narrow drawers.  Without this, the .toolbar-group
   wrap rule is free to break between the two — the chevron lands on
   its own line, orphaned. */
.btn-pair {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  flex-wrap: nowrap;
  flex-shrink: 0;
}

.toolbar-label {
  color: var(--fg-dim);
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.6px;
  padding: 0 2px 0 4px;
  font-weight: 500;
  flex-shrink: 0;
  user-select: none;
}

.proj-hyperbolic-toggle {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 2px 8px;
  border-radius: 3px;
  font-size: 10px;
  color: var(--fg-dim);
  font-weight: bold;
  letter-spacing: 1px;
  text-transform: uppercase;
  cursor: pointer;
  user-select: none;
  transition: background 0.15s, color 0.15s;
}
.proj-hyperbolic-toggle:hover {
  background: var(--toolbar-border);
  color: var(--fg);
}
.proj-hyperbolic-toggle input { margin: 0; accent-color: var(--accent); }

/* Nested variant — sits inside the 2D·Ortho button label as part of
   the button's text.  No background / padding of its own: it inherits
   the button's active styling, the checkbox is the only distinguishing
   glyph.  Clicks on the checkbox are stopPropagation'd in JS so the
   outer button doesn't also snap-to-flat. */
.proj-hyperbolic-nested {
  background: transparent;
  padding: 0;
  margin: 0;
  color: inherit;
  text-transform: inherit;
  letter-spacing: inherit;
  font-size: inherit;
  font-weight: inherit;
}
/* Telltale: the "Hyperbolic" label echoes the checkbox state.
   Unchecked → dim & regular weight (the option is present but off);
   checked → full intensity & bold (the option is engaged). */
.proj-hyperbolic-nested span {
  opacity: 0.5;
  font-weight: normal;
  transition: opacity 0.15s, font-weight 0.15s;
}
.proj-hyperbolic-nested input:checked + span {
  opacity: 1;
  font-weight: bold;
}
/* The three-row 2D / ☑ Hyperbolic / Ortho layout: center each row
   and tighten line-height so the stack reads as one button, not a
   paragraph.  The <br>s in the HTML drive the wrapping. */
#proj-flat {
  text-align: center;
  line-height: 1.15;
}
.proj-hyperbolic-nested:hover { background: transparent; color: inherit; }
.proj-toggle.active .proj-hyperbolic-nested { color: var(--bg); }

.toolbar-status {
  font-size: 11px;
  color: var(--fg-dim);
}

.spacer { flex: 1; }

.sep {
  width: 1px; height: 18px;
  background: var(--toolbar-border);
  margin: 0 2px;
  flex-shrink: 0;
}

/* === Main: left panel + viewport + right panel === */
#main {
  grid-row: 2;
  display: grid;
  grid-template-columns:
    var(--panel-l-w) var(--gutter-w) 1fr var(--gutter-w) var(--panel-r-w);
  overflow: hidden;
}

/* Resize gutters between the panels and the viewport. Drag to resize,
   click the embedded chevron to collapse / expand the adjacent panel. */
.gutter {
  position: relative;
  background: var(--toolbar-border);
  cursor: col-resize;
  user-select: none;
  touch-action: none;
}
.gutter:hover { background: #4a9eff; }

/* Horizontal gutter (between viewport row and bottom panel).  Uses
   row-resize cursor and the toggle is rotated to a flat pill. */
.gutter.gutter-h {
  grid-row: 3;
  grid-column: 1 / -1;
  cursor: row-resize;
}
.gutter.gutter-h .gutter-toggle {
  width: 44px;
  height: 14px;
  font-size: 9px;
}

.gutter-toggle {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 14px;
  height: 44px;
  padding: 0;
  background: var(--panel-bg);
  color: var(--fg);
  border: 1px solid var(--toolbar-border);
  border-radius: 3px;
  cursor: pointer;
  font-size: 9px;
  line-height: 1;
  z-index: 2;
}
.gutter-toggle:hover { background: var(--hover); color: #4a9eff; }

/* Collapse by zero width (not display:none) so the grid keeps all 5
   tracks; otherwise a display:none panel shifts the remaining children
   into the wrong columns and the viewport ends up 6px wide. */
.panel { overflow: hidden; }
.panel.collapsed { visibility: hidden; min-width: 0; }
#bottom.collapsed { visibility: hidden; min-height: 0; }

/* === Panels === */
.panel {
  background: var(--panel-bg);
  border-right: 1px solid var(--toolbar-border);
  overflow-y: auto;
  overflow-x: hidden;
}

#panel-right {
  border-right: none;
  border-left: 1px solid var(--toolbar-border);
}

.panel-header {
  padding: 6px 10px;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 1px;
  color: var(--fg-dim);
  border-bottom: 1px solid var(--toolbar-border);
  background: var(--toolbar-bg);
  position: sticky;
  top: 0;
}

/* === Tree view === */
.tree {
  padding: 4px 0;
}

.tree-item {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 3px 10px;
  cursor: pointer;
  transition: background 0.1s;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.tree-item:hover {
  background: var(--hover);
}

.tree-item.selected {
  background: rgba(74, 158, 255, 0.15);
  color: var(--accent);
}

.tree-icon {
  width: 8px; height: 8px;
  border-radius: 50%;
  flex-shrink: 0;
}

.tree-icon.rank-0 { background: var(--scalar); }
.tree-icon.rank-1 { background: var(--vector-color); border-radius: 2px; }
.tree-icon.rank-2 { background: var(--matrix-color); border-radius: 0; }
.tree-icon.rank-3 { background: var(--higher-rank); border-radius: 50%; border: 1px solid var(--higher-rank); background: transparent; }

.tree-mag {
  color: var(--fg-dim);
  font-size: 10px;
  margin-left: auto;
}

/* === Properties panel === */
.prop-empty {
  padding: 8px;
  color: var(--fg-dim);
}

.prop-section {
  color: var(--fg-dim);
  margin-top: 4px;
}

.prop-sub {
  padding-left: 18px;
}

.prop-tensor {
  padding: 6px 0;
}

.prop-id {
  color: var(--fg-dim);
  font-size: 10px;
  margin-left: 6px;
}

.prop-desc {
  color: var(--fg);
  padding: 6px 8px;
  margin: 4px 0;
  white-space: pre-wrap;
  border-left: 2px solid var(--toolbar-border);
  font-size: 11px;
  line-height: 1.4;
}

.prop-storage {
  margin: 2px 0 6px 0;
  padding: 4px 8px;
  max-height: 200px;
  overflow: auto;
  background: rgba(0, 0, 0, 0.25);
  color: var(--fg);
  font-family: ui-monospace, monospace;
  font-size: 10px;
  white-space: pre;
  border-radius: 3px;
}

.prop-source {
  color: #4a9eff;
  text-decoration: none;
  font-size: 11px;
}
.prop-source:hover { text-decoration: underline; }

.prop-divider {
  border: none;
  border-top: 1px dashed var(--toolbar-border);
  margin: 6px 0;
}

/* === Source viewer (opened from Data panel "source:" link) === */
#source-viewer {
  position: fixed;
  right: 16px;
  top: 60px;
  bottom: 16px;
  width: 560px;
  max-width: calc(100vw - 32px);
  z-index: 10003;
  background: rgba(12, 12, 20, 0.97);
  border: 1px solid var(--toolbar-border);
  border-radius: 6px;
  display: flex;
  flex-direction: column;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
#source-viewer .sv-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 12px;
  border-bottom: 1px solid var(--toolbar-border);
}
#source-viewer .sv-title {
  color: #4a9eff;
  font-family: ui-monospace, monospace;
  font-size: 12px;
}
#source-viewer .sv-close {
  background: transparent;
  border: none;
  color: var(--fg-dim);
  font-size: 18px;
  cursor: pointer;
  padding: 0 6px;
}
#source-viewer .sv-close:hover { color: var(--fg); }
#source-viewer .sv-body {
  flex: 1;
  margin: 0;
  padding: 8px 12px;
  overflow: auto;
  color: var(--fg);
  font-family: ui-monospace, monospace;
  font-size: 11px;
  line-height: 1.5;
  white-space: pre;
}
#source-viewer .sv-ln {
  color: var(--fg-dim);
  user-select: none;
  display: inline-block;
  margin-right: 8px;
}
#source-viewer .sv-hl {
  display: block;
  background: rgba(74, 158, 255, 0.18);
  border-left: 2px solid #4a9eff;
  margin-left: -12px;
  padding-left: 10px;
}

/* === 3D Viewport === */
#scene {
  position: relative;
  overflow: hidden;
}

#scene canvas {
  display: block;
  overflow: hidden; /* suppress Chromium auto-scroll on middle-click */
  touch-action: none; /* we handle 1-finger rotate + 2-finger pinch ourselves */
}

/* CSS2DRenderer overlay (tensor labels etc.) */
.label-layer {
  position: absolute;
  top: 0;
  left: 0;
  pointer-events: none;
  /* Establish a stacking context so per-label z-index values (set by
     CSS2DRenderer for depth sorting) stay CONTAINED and cannot punch
     through overlay panels like #proj-slider-wrap or #timeline-wrap. */
  z-index: 1;
  isolation: isolate;
}

/* === Timeline scrubber (top of viewport) ===
   Horizontal strip showing the current branch's root→HEAD path in the
   contraction tree.  Each tick is a history-node; dragging the slider
   replays via history-goto.  A fork glyph on a tick indicates the
   node has siblings (branch switching — phase 2). */
#timeline-wrap {
  /* Flows as a sibling row inside #viewport-top — no absolute
     positioning needed.  The flex column above gives it the same
     left/right margins as the seed-row and proj-slider, and the
     gap rule keeps the rows from touching.
     Wheel-pass-through: the wrap's background was absorbing scroll
     events; pointer-events:none on the wrap, auto on the inner
     controls, lets wheel events fall through to the canvas. */
  display: grid;
  pointer-events: none;
  background: rgba(30, 32, 38, 0.85);
  border: 1px solid var(--toolbar-border);
  border-radius: 6px;
  padding: 6px 10px 4px 10px;
  font-family: 'JetBrains Mono', monospace;
  font-size: 10px;
  user-select: none;
  backdrop-filter: blur(6px);
  grid-template-columns: 1fr auto auto;
  column-gap: 8px;
  align-items: center;
}
#timeline-wrap > * { pointer-events: auto; }
#timeline-wrap #timeline-ticks { pointer-events: none; }
#timeline-wrap #timeline-ticks .tick { pointer-events: auto; }
#timeline-keyframe {
  align-self: center;
  width: 22px;
  height: 22px;
  border-radius: 4px;
  border: 1px solid var(--toolbar-border);
  background: rgba(255,255,255,0.06);
  color: var(--fg);
  font: inherit;
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
  padding: 0;
}
#timeline-keyframe:hover {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}
#timeline-track {
  position: relative;
  height: 18px;
}
#timeline-slider {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 18px;
  margin: 0;
  background: transparent;
  z-index: 2;
  cursor: pointer;
  /* body has `touch-action: none` (we own all canvas gestures), but
     the native range-input needs touch-action set explicitly so iOS
     stylus/touch can drive the slider.  `manipulation` allows tap +
     drag along the input's axis, suppresses the iOS double-tap-to-
     zoom delay, and leaves pinch-zoom alone (none happens here
     anyway since the body rule blocks it elsewhere). */
  touch-action: manipulation;
}
#timeline-ticks {
  position: absolute;
  top: 4px;
  left: 0;
  width: 100%;
  height: 10px;
  z-index: 3;  /* Above the slider input so ticks can receive pointer events. */
  pointer-events: none;
}
#timeline-ticks .tick {
  position: absolute;
  top: 3px;
  width: 1px;
  height: 4px;
  background: var(--fg-dim);
  transform: translateX(-50%);
  pointer-events: auto;
  cursor: pointer;
  /* Hit-zone: a 6×6 dot is too small for a right-click target,
     especially on touch.  Pad the interactive area to ~22×22 via a
     transparent ::before that overlays the visible glyph.  The dot
     itself stays its design size; only the catch-area grows. */
}
#timeline-ticks .tick::before {
  content: '';
  position: absolute;
  top: -8px;
  bottom: -8px;
  left: -10px;
  right: -10px;
}
/* Auto-nodes: hairline — default .tick rules apply.  Class added for
   clarity and future styling hooks. */
#timeline-ticks .tick.auto {
  width: 1px;
  opacity: 0.55;
}
/* Keyframes: bold filled dots so they stand out from the auto-node crowd. */
#timeline-ticks .tick.keyframe {
  width: 6px;
  height: 6px;
  top: 2px;
  border-radius: 50%;
  background: var(--vector-color);
  opacity: 1;
}
#timeline-ticks .tick.head {
  background: var(--accent);
  box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.35);
  z-index: 2;
}
#timeline-ticks .tick.keyframe.head {
  background: var(--accent);
}
#timeline-ticks .tick.fork {
  outline: 1px solid var(--vector-color);
}
#timeline-ticks .tick.dragging {
  opacity: 0.4;
  background: var(--accent);
}
#timeline-ticks .tick:hover {
  background: var(--accent);
  opacity: 1;
}

/* --- Timeline context menu, rename, branch switcher --- */
.timeline-ctx-menu {
  position: fixed;
  z-index: 10002;
  min-width: 200px;
  background: rgba(12, 12, 20, 0.96);
  border: 1px solid var(--accent);
  border-radius: 4px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
  padding: 4px 0;
  backdrop-filter: blur(4px);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 11px;
}
.timeline-ctx-menu .ctx-header {
  padding: 4px 10px 6px;
  border-bottom: 1px solid var(--toolbar-border);
  color: var(--fg-dim);
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-bottom: 4px;
  max-width: 280px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.timeline-ctx-menu .ctx-item {
  padding: 5px 14px;
  cursor: pointer;
  color: var(--fg);
  white-space: nowrap;
}
.timeline-ctx-menu .ctx-item:hover {
  background: rgba(74, 158, 255, 0.18);
  color: var(--accent);
}
.timeline-ctx-menu .ctx-item.disabled {
  color: var(--fg-dim);
  cursor: default;
  pointer-events: none;
  opacity: 0.45;
}
.timeline-rename-input {
  position: absolute;
  z-index: 10003;
  width: 160px;
  padding: 2px 6px;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 11px;
  background: rgba(12, 12, 20, 0.98);
  color: var(--fg);
  border: 1px solid var(--accent);
  border-radius: 3px;
  outline: none;
  transform: translateX(-50%);
}
.tensor-rename-input {
  padding: 4px 8px;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 12px;
  background: rgba(12, 12, 20, 0.98);
  color: var(--fg);
  border: 1px solid var(--accent);
  border-radius: 3px;
  outline: none;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
}
.tensor-rename-input:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.3);
}
.timeline-branch-switcher {
  position: absolute;
  right: 8px;
  top: -2px;
  display: flex;
  align-items: center;
  gap: 4px;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 10px;
  color: var(--fg-dim);
  z-index: 4;
}
.timeline-branch-switcher button {
  background: transparent;
  border: 1px solid var(--toolbar-border);
  color: var(--fg);
  cursor: pointer;
  padding: 1px 5px;
  border-radius: 2px;
  font-size: 10px;
  line-height: 1;
}
.timeline-branch-switcher button:hover {
  border-color: var(--accent);
  color: var(--accent);
}
#timeline-label {
  display: flex;
  gap: 10px;
  color: var(--fg-dim);
  margin-top: 2px;
  overflow: hidden;
  white-space: nowrap;
}
#timeline-pos {
  color: var(--accent);
  flex: 0 0 auto;
}
#timeline-desc {
  color: var(--fg);
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Broadcast bar — bottom-center of the viewport.  Two roles, one
   element: visitors get JOIN/LEAVE on a live broadcast, broadcast-
   capable sessions (?broadcast URL or owner room) get GO LIVE / END
   BROADCAST.  Sized prominently — this is the demo's headline.
   Hidden when the visitor has nothing to do (no broadcast, not a
   broadcaster). */
#broadcast-bar[hidden] { display: none !important; }
#broadcast-bar {
  position: absolute;
  bottom: calc(env(safe-area-inset-bottom, 0px) + 18px);
  left: 50%;
  transform: translateX(-50%);
  z-index: 30;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 10px 14px 10px 18px;
  background: rgba(12, 12, 20, 0.94);
  border: 2px solid #2ed4c9;
  border-radius: 999px;
  color: #2ed4c9;
  font-family: 'JetBrains Mono', monospace;
  font-size: 14px;
  font-weight: 500;
  letter-spacing: 0.04em;
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  box-shadow: 0 0 24px rgba(46, 212, 201, 0.35);
}
#broadcast-bar.opted-out,
#broadcast-bar.idle {
  color: var(--fg-dim);
  border-color: var(--toolbar-border);
  box-shadow: none;
}
#broadcast-bar .bb-icon { font-size: 18px; }
#broadcast-bar .bb-toggle {
  background: transparent;
  border: 2px solid currentColor;
  color: inherit;
  padding: 8px 18px;
  border-radius: 999px;
  font-family: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  min-height: 36px;
  min-width: 110px;
}
#broadcast-bar .bb-toggle:hover { background: rgba(46, 212, 201, 0.22); }
#broadcast-bar.live .bb-toggle {
  background: #2ed4c9; color: #0a0a0f;
}
#broadcast-bar.live .bb-toggle:hover { background: #4be0d6; }
/* Passive spectator indicator — small, dim, no glow, no button.
   Tells the viewer they're locked to a broadcast without offering
   controls they shouldn't have. */
#broadcast-bar.passive {
  padding: 4px 12px;
  font-size: 11px;
  letter-spacing: 0.05em;
  border-color: rgba(46, 212, 201, 0.35);
  color: rgba(46, 212, 201, 0.7);
  box-shadow: none;
  background: rgba(12, 12, 20, 0.6);
}

/* === Agent seed strip (top overlay on viewport) ===
   Paste/type labels → POST agent-seed → N rank-0 agent-origin tensors.
   Additive to curated data; the teal agent-origin ring distinguishes
   seeded glyphs from curated ones. */
/* === Composable top-bar overlay ===
   #viewport-top is a flex COLUMN absolute-positioned at the top of the
   #scene viewport.  Each direct child (#viewport-top-seed-row, the proj
   slider, etc.) is a row that flows naturally — hide any one with
   display:none and the remaining rows stay centred, aligned, and
   evenly spaced without further math.  Children opt back into pointer
   events; the empty container itself ignores them so the canvas
   underneath stays interactive. */
#viewport-top {
  position: absolute;
  top: 8px;
  top: calc(env(safe-area-inset-top, 0px) + 8px);
  left: 8px;
  left: calc(env(safe-area-inset-left, 0px) + 8px);
  right: 8px;
  right: calc(env(safe-area-inset-right, 0px) + 8px);
  display: flex;
  flex-direction: column;
  gap: 8px;
  z-index: 10;
  pointer-events: none;
}
#viewport-top > * {
  pointer-events: auto;
}
#viewport-top-seed-row.vt-row {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 8px;
}
#agent-seed-strip {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px;
  background: rgba(12, 12, 20, 0.82);
  border: 1px solid rgba(46, 212, 201, 0.35);  /* teal like agent-origin ring */
  border-radius: 4px;
  font-size: 11px;
  backdrop-filter: blur(4px);
  flex: 1 1 auto;
  min-width: 0;
}
#agent-seed-input {
  flex: 1 1 auto;
  background: rgba(0, 0, 0, 0.35);
  color: var(--fg);
  border: 1px solid var(--toolbar-border);
  border-radius: 3px;
  padding: 4px 7px;
  font-family: inherit;
  font-size: 11px;
  line-height: 1.3;
  resize: none;
  overflow: hidden;
  min-height: 20px;
  max-height: 120px;
}
#agent-seed-input:focus { outline: 1px solid rgba(46, 212, 201, 0.7); }
#agent-seed-btn {
  background: rgba(46, 212, 201, 0.18);
  color: #2ed4c9;
  border: 1px solid rgba(46, 212, 201, 0.55);
  border-radius: 3px;
  padding: 3px 10px;
  font-family: inherit;
  font-size: 11px;
  cursor: pointer;
  flex: 0 0 auto;
}
#agent-seed-btn:hover { background: rgba(46, 212, 201, 0.32); }
#agent-seed-btn:disabled { opacity: 0.4; cursor: default; }
/* Generate-mode ON: solid teal so the user knows the oracle will fire
   on dive-approach.  OFF: default outlined style — generation is opt-in. */
#agent-seed-btn.generate-on {
  background: #2ed4c9;
  color: #0a1418;
  border-color: #2ed4c9;
}
#agent-seed-btn.generate-on:hover { background: #4be0d6; }
/* Coding-agent-mode ON: warm amber so the chief sees that the next
   submit goes to the local Claude Code session, not the API. */
#agent-seed-btn.coding-agent-on {
  background: #ffb84d;
  color: #1a0f00;
  border-color: #ffb84d;
}
#agent-seed-btn.coding-agent-on:hover { background: #ffc266; }
/* Find-mode ON: gold so the chief sees the button has become FIND
   and the gold tint on hit tensors reads as the same affordance. */
#agent-seed-btn.find-on {
  background: #ffd700;
  color: #1a1300;
  border-color: #ffd700;
}
#agent-seed-btn.find-on:hover { background: #ffe14d; }

/* Split-button — primary action + chevron together, share the same
   pill shape. */
.seed-split {
  display: inline-flex;
  align-items: stretch;
  flex: 0 0 auto;
}
.seed-split-chevron {
  margin-left: 2px;
  padding: 0 8px;
  background: rgba(46, 212, 201, 0.12);
  border: 1px solid rgba(46, 212, 201, 0.5);
  border-radius: 4px;
  color: #2ed4c9;
  font-size: 11px;
  cursor: pointer;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.seed-split-chevron:hover { background: rgba(46, 212, 201, 0.28); }
.seed-split-menu {
  /* Fixed positioning so an ancestor's overflow:hidden can't clip
     the dropdown.  JS sets top/right per-open from the chevron's
     getBoundingClientRect; the values below are fallbacks. */
  position: fixed;
  top: 60px;
  right: 8px;
  min-width: 240px;
  background: rgba(12, 12, 20, 0.96);
  border: 1px solid var(--toolbar-border);
  border-radius: 4px;
  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.5);
  padding: 4px;
  z-index: 9999;
}
.seed-split { position: relative; }
.seed-mode-opt {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  width: 100%;
  padding: 6px 10px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 3px;
  color: var(--fg-dim);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  text-align: left;
  cursor: pointer;
}
.seed-mode-opt:hover {
  background: rgba(46, 212, 201, 0.10);
  border-color: rgba(46, 212, 201, 0.25);
  color: #cfe6ff;
}
.seed-mode-opt--active {
  background: rgba(46, 212, 201, 0.18);
  border-color: rgba(46, 212, 201, 0.55);
  color: #2ed4c9;
}
.seed-mode-label { font-size: 11px; font-weight: 600; letter-spacing: 0.04em; }
.seed-mode-hint  { font-size: 10px; opacity: 0.7; }

/* Sub-config row under a mode option — currently used by CODING AGENT
   to expose the per-file tmux pane.  Indented so its visual scope is
   clearly "belongs to the option above". */
.seed-mode-config {
  padding: 4px 10px 8px 22px;
  border-left: 1px solid rgba(46, 212, 201, 0.18);
  margin-left: 10px;
}
.seed-mode-config-label {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 10px;
  color: var(--fg-dim);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.seed-mode-config-label > span { opacity: 0.7; min-width: 60px; }
.seed-mode-config-label > input {
  flex: 1;
  background: rgba(0, 0, 0, 0.35);
  border: 1px solid rgba(46, 212, 201, 0.25);
  border-radius: 3px;
  padding: 3px 6px;
  color: #cfe6ff;
  font-family: inherit;
  font-size: 11px;
  outline: none;
}
.seed-mode-config-label > input:focus {
  border-color: rgba(46, 212, 201, 0.85);
  background: rgba(0, 0, 0, 0.55);
}
.seed-mode-config-label > input.coding-agent-target-input--dirty {
  border-color: rgba(255, 178, 102, 0.7);
}

/* Projection + camera slider — child of #viewport-top, flows in the
   column.  Width follows the parent (the row is a `vt-row`). */
#proj-slider-wrap {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 8px 14px;
  background: rgba(12, 12, 20, 0.82);
  border: 1px solid var(--toolbar-border);
  border-radius: 4px;
  font-size: 11px;
  color: var(--fg);
  backdrop-filter: blur(4px);
}

.slider-row {
  display: flex;
  align-items: center;
  gap: 10px;
}

.proj-title {
  color: var(--fg-dim);
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.8px;
  padding-right: 6px;
  border-right: 1px solid var(--toolbar-border);
  margin-right: 2px;
  min-width: 64px;
  text-align: right;
}

.proj-label {
  font-weight: bold;
  letter-spacing: 1px;
  color: var(--fg-dim);
}
.proj-toggle {
  cursor: pointer;
  padding: 2px 6px;
  border-radius: 3px;
  transition: background 0.15s, color 0.15s;
  user-select: none;
}
.proj-toggle:hover {
  background: var(--toolbar-border);
  color: var(--fg);
}
.proj-toggle.active {
  background: var(--accent);
  color: var(--bg);
}

#proj-slider, #cam-slider {
  width: 240px;
  -webkit-appearance: none;
  appearance: none;
  height: 4px;
  background: var(--toolbar-border);
  border-radius: 2px;
  outline: none;
}

#proj-slider::-webkit-slider-thumb,
#cam-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--accent);
  cursor: pointer;
  box-shadow: 0 0 6px rgba(74, 158, 255, 0.6);
}

#proj-slider::-moz-range-thumb,
#cam-slider::-moz-range-thumb {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--accent);
  cursor: pointer;
  border: none;
  box-shadow: 0 0 6px rgba(74, 158, 255, 0.6);
}

#proj-value, #cam-value {
  color: var(--accent);
  font-family: inherit;
  min-width: 32px;
  text-align: right;
}

/* Granularity slider — orange accent2 thumb */
#gran-slider {
  width: 240px;
  -webkit-appearance: none;
  appearance: none;
  height: 4px;
  background: var(--toolbar-border);
  border-radius: 2px;
  outline: none;
}

#gran-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--accent2);
  cursor: pointer;
  box-shadow: 0 0 6px rgba(255, 107, 74, 0.6);
}

#gran-slider::-moz-range-thumb {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--accent2);
  cursor: pointer;
  border: none;
  box-shadow: 0 0 6px rgba(255, 107, 74, 0.6);
}

#gran-value {
  color: var(--accent2);
  font-family: inherit;
  min-width: 32px;
  text-align: right;
}

/* Link checkboxes: a small square glyph on each slider row; both
   checked = the two sliders drive each other. Styled to look like
   a tiny chain link. */
.link-box {
  appearance: none;
  -webkit-appearance: none;
  width: 14px;
  height: 14px;
  border: 1px solid var(--toolbar-border);
  border-radius: 3px;
  background: transparent;
  cursor: pointer;
  position: relative;
  margin-left: 4px;
  flex-shrink: 0;
}

.link-box:hover {
  border-color: var(--accent);
}

.link-box::before {
  content: "\1F517"; /* 🔗 chain link */
  position: absolute;
  left: 0;
  top: -2px;
  width: 100%;
  text-align: center;
  font-size: 10px;
  color: var(--fg-dim);
  line-height: 14px;
}

.link-box:checked {
  border-color: var(--accent);
  background: rgba(74, 158, 255, 0.18);
}

.link-box:checked::before {
  color: var(--accent);
}

/* === Bottom: dashboard + terminal === */
#bottom {
  grid-row: 4;
  display: grid;
  grid-template-columns: 1fr 1fr;
  /* Explicit minmax(0,1fr) row lets children shrink below content size;
     without it, the implicit row grows to fit the tallest child and the
     terminal's input row falls below the viewport. */
  grid-template-rows: minmax(0, 1fr);
  border-top: 1px solid var(--toolbar-border);
  background: var(--panel-bg);
  min-height: 0;
  overflow: hidden;
}

#dashboard {
  border-right: 1px solid var(--toolbar-border);
}

.dash-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1px;
  padding: 6px;
}

.dash-cell {
  display: flex;
  flex-direction: column;
  padding: 6px 8px;
  background: var(--bg2);
  border-radius: 3px;
}

.dash-label {
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.8px;
  color: var(--fg-dim);
  margin-bottom: 2px;
}

.dash-value {
  font-size: 16px;
  font-weight: bold;
  color: var(--accent);
}

/* === Terminal === */
#terminal {
  display: flex;
  flex-direction: column;
  min-height: 0;
  height: 100%;
  overflow: hidden;
}

.term-scroll {
  flex: 1;
  overflow-y: auto;
  /* Integer line-height (11px × 1.5 = 16.5px was fractional → last line
     got half-cut at the bottom) + extra bottom padding so the latest
     auto-scrolled line always has a clear baseline. */
  padding: 6px 10px 10px 10px;
  font-size: 11px;
  line-height: 16px;
}

.term-line {
  white-space: pre-wrap;
  word-break: break-all;
}

.term-line .ts {
  color: var(--fg-dim);
  margin-right: 6px;
}

.term-line.info { color: var(--fg); }
.term-line.ok { color: var(--scalar); }
.term-line.warn { color: var(--vector-color); }
.term-line.err { color: var(--accent2); }
.term-line.reduction { color: var(--accent); }
.term-line.cmd { color: var(--accent); font-weight: 500; }

/* Terminal input row — agent interface.
   Always visible below the scrolling log; never overflows, never
   steals focus from the viewport unless the user clicks it. */
#term-form {
  display: flex;
  align-items: center;
  flex: 0 0 auto;
  border-top: 1px solid var(--toolbar-border);
  padding: 4px 10px;
  background: var(--bg2);
}
.term-prompt {
  color: var(--accent);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 12px;
  margin-right: 6px;
  user-select: none;
}
#term-input {
  flex: 1;
  background: transparent;
  border: none;
  outline: none;
  color: var(--fg);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 11px;
  line-height: 16px;
  padding: 2px 0;
}
#term-input::placeholder {
  color: var(--fg-dim);
  opacity: 0.7;
}
#term-input:disabled {
  color: var(--fg-dim);
  cursor: not-allowed;
}

/* === CSS2D labels (Three.js overlay) === */
/* Importance-driven label display: font-size and opacity are set per
   frame by updateLabels() in scene.js based on rank, connectivity,
   magnitude, selection state, zoom level, and screen-space collision.
   CSS transitions smooth the changes so labels fade gracefully. */
/* Script-card wrapper: 0×0 anchor at the glyph centre.  The visible
   .tensor-script inside is absolutely positioned a fixed pixel distance
   below the anchor — independent of camera zoom or projection — so the
   card stays out of the glyph's way at any scale. */
.tensor-script-wrap {
  position: relative;
  width: 0;
  height: 0;
  pointer-events: none;
}
.tensor-script {
  /* OPT-IN by file (viewport.scriptCards) or user toggle (btn-script-cards).
     Hidden by default — body.show-script-cards reveals them.  Same pattern
     as the depth-fade and show-hidden filters. */
  display: none;
  position: absolute;
  top: 44px;            /* fixed screen-pixel gap below the glyph anchor */
  left: 50%;
  transform: translateX(-50%);
  color: #eaf0fa;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 11px;
  line-height: 1.15;
  pointer-events: none;
  align-items: center;
  gap: 6px;
  background: rgba(10, 14, 22, 0.82);
  padding: 4px 10px 4px 8px;
  border-radius: 4px;
  border: 1px solid rgba(140, 170, 200, 0.18);
  white-space: nowrap;
  text-shadow: 0 0 2px rgba(0,0,0,0.9);
}
body.show-script-cards .tensor-script {
  display: inline-flex;
}
.tensor-script .ss-sym {
  font-size: 15px;
  font-weight: 600;
  color: #ffd080;
}
.tensor-script .ss-eq {
  opacity: 0.55;
}
.tensor-script .ss-scalar {
  font-size: 14px;
  color: #e8d8a8;
  font-variant-numeric: tabular-nums;
}
.tensor-script .ss-shape {
  opacity: 0.7;
  font-style: italic;
}
.tensor-script .ss-mat {
  display: grid;
  grid-template-columns: repeat(var(--cols, 1), minmax(22px, auto));
  column-gap: 7px;
  row-gap: 1px;
  padding: 2px 9px;
  position: relative;
}
.tensor-script .ss-mat > div {
  text-align: right;
  font-variant-numeric: tabular-nums;
}
/* Matrix/vector brackets drawn as border-ticks on ::before/::after.
   Vertical bar + short horizontal caps top and bottom = [ and ]. */
.tensor-script .ss-mat::before,
.tensor-script .ss-mat::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  width: 4px;
  border: 1.5px solid rgba(200, 220, 240, 0.75);
}
.tensor-script .ss-mat::before {
  left: 0;
  border-right: none;
}
.tensor-script .ss-mat::after {
  right: 0;
  border-left: none;
}
/* Wrapper: 0×0 anchor pinned to glyph's projected point by CSS2DRenderer.
   The visible text lives in .tensor-label-text inside, positioned in
   SCREEN PIXELS so it always reads as "above" the glyph regardless of
   camera angle.  World-space offsets only worked under one camera pose. */
.tensor-label {
  width: 0;
  height: 0;
  pointer-events: none;
}
/* Per-file label suppression — set by readViewportPref(state.viewport,
   'hideLabels').  The transformer file uses this so the canvas stays
   clean while widgets are the primary UI. */
body.hide-labels .tensor-label,
body.hide-labels .script-card {
  display: none;
}

/* Generic HTML widget anchored to a tensor.  Sidecars set
   tensor.widgetHtml (server side) and the frontend renders the bytes
   verbatim inside this wrapper.  Wrapper is a 0×0 anchor that
   CSS2DRenderer pins to the projected glyph point; the actual UI
   inside is offset DOWN from the glyph (label sits up by default).
   pointer-events:auto so dropdowns/forms get clicks. */
.tensor-widget {
  width: 0;
  height: 0;
  pointer-events: auto;
}
.tensor-widget > * {
  position: absolute;
  left: 0;
  top: 16px;          /* sits just below the glyph */
  transform: translate(-50%, 0);
  font: 12px/1.3 system-ui, sans-serif;
  color: #cdd6df;
  background: rgba(11,17,23,0.85);
  border: 1px solid #2a3a4a;
  border-radius: 3px;
  padding: 2px 6px;
  white-space: nowrap;
}
.tensor-widget select,
.tensor-widget input,
.tensor-widget button {
  background: #0b1117;
  color: #cdd6df;
  border: 1px solid #2a3a4a;
  border-radius: 2px;
  padding: 1px 4px;
  font: inherit;
}
/* Ghosted state for labels of hidden tensors — the wrapper gets
   .tensor-ghosted (set by syncTensorOpacity); the inner text dims.
   !important beats the inline opacity that updateLabels writes for
   the per-frame zoom fade. */
.tensor-label.tensor-ghosted .tensor-label-text {
  opacity: 0.35 !important;
}
.tensor-label-text {
  position: absolute;
  left: 0;
  /* top: SET INLINE by updateLabels (per-glyph proportional gap).
     transform: SET INLINE — `translate(-50%, -100%) scale(s)` with
     `transform-origin: 50% 100%` keeps the bottom-centre anchored
     at the wrapper origin under any scale.  GPU-accelerated, no
     font re-rasterization, no line-wrap reflow when the scale
     changes — the page just smoothly grows/shrinks the layer. */
  top: -22px;
  transform: translate(-50%, -100%);
  transform-origin: 50% 100%;
  /* Wrapping geometry, "good rhomboid / golden rectangle" rule:
     a label should occupy a wide-rectangular box, NOT a tower of
     single words.  `width: max-content` lets the box grow to the
     full natural line width when it fits; `max-width` caps it for
     long labels, forcing graceful multi-word wraps.  Without
     max-content, an absolute-positioned box shrinks to its
     LONGEST WORD's width — so "Spacecraft as Tensors" stacks
     vertically even though the whole phrase is only ~180px wide.
     `min-width` keeps single very-short labels from rendering
     uncomfortably narrow when scaled small. */
  width: max-content;
  min-width: 60px;
  max-width: 180px;
  /* pre-line preserves user-inserted \n line breaks (Shift+Enter
     in the in-place rename editor) while still collapsing runs of
     spaces — same wrap behaviour as `normal` for soft wraps, plus
     honour for explicit breaks. */
  /* pre-line: collapse runs of spaces, honour explicit \n.
     break-word: normally wrap at word boundaries, but break a
     too-long-for-max-width word mid-letter rather than overflow.
     Predictable: what's stored is what's shown; only the soft-wrap
     point can move with max-width changes. */
  white-space: pre-line;
  overflow-wrap: break-word;
  word-break: normal;
  color: var(--fg);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 14px;
  font-weight: 500;
  pointer-events: none;
  text-shadow: 0 0 3px rgba(0,0,0,0.95), 0 0 6px rgba(0,0,0,0.8),
               0 0 12px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.9);
  text-align: center;
  line-height: 1.3;
  /* No CSS transition on font-size — it animated the JS-driven size
     change on every refresh ("inflate") and on every zoom step.  The
     JS pass already computes the target size cleanly each frame; we
     don't want the browser interpolating between values. */
  will-change: opacity;
}

/* Figure-caption variant — applied to raster-node labels.  Same
   font, same size, same scaling rule as regular labels — only
   differences are: italic + slightly dimmer (print-caption tone),
   and transform-origin flipped to top-centre so the per-frame
   scale() pins the caption's TOP edge at the wrapper anchor
   (the glyph's bottom rim + gap, set by updateLabels). */
.tensor-label-text.tensor-label--caption {
  transform-origin: 50% 0%;
  font-style: italic;
  color: rgba(220, 228, 240, 0.92);
}
/* Hyperlink raster — caption text turns warm-blue instead of dim
   off-white so the user reads "this image is a portal" at a glance,
   without the heavy Saturn ring overlay used on vector glyphs.  When
   the raster has no caption the same blue paints the boundary ring
   on the panel itself (set in scene.js syncHyperlinkIndicator). */
.tensor-label-text.tensor-label--hyperlink {
  color: rgba(110, 175, 255, 0.95);
  /* The label IS the link — accept clicks (parent label-text rule
     has pointer-events:none for ordinary captions).  Hover hint:
     pointer cursor so the affordance reads at a glance. */
  pointer-events: auto;
  cursor: pointer;
}
.tensor-label-text.tensor-label--hyperlink:hover {
  color: rgba(150, 200, 255, 1);
  text-decoration: underline;
}

/* === Hover tooltip (tensors and bonds) === */
#hover-tip {
  position: fixed;
  z-index: 10000;
  pointer-events: none;
  max-width: 360px;
  padding: 8px 10px;
  background: rgba(12, 12, 20, 0.96);
  border: 1px solid var(--accent);
  border-radius: 4px;
  color: var(--fg);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 11px;
  line-height: 1.45;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
}
#hover-tip .tip-kind {
  color: var(--accent);
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-bottom: 2px;
}
#hover-tip .tip-title {
  color: #fff;
  font-weight: 600;
  font-size: 13px;
  margin-bottom: 3px;
}
#hover-tip .tip-meta {
  color: var(--fg-dim);
  font-size: 10px;
  margin-bottom: 4px;
}
#hover-tip .tip-desc {
  color: var(--fg);
  margin-top: 4px;
  padding-top: 5px;
  border-top: 1px dashed var(--toolbar-border);
  white-space: pre-wrap;
}

/* === Context menu (right-click transform on selection) === */
#context-menu {
  position: fixed;
  z-index: 10001;
  min-width: 180px;
  background: rgba(12, 12, 20, 0.96);
  border: 1px solid var(--accent);
  border-radius: 4px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
  padding: 4px 0;
  display: none;
  backdrop-filter: blur(4px);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 11px;
}
#context-menu .ctx-header {
  padding: 4px 10px 6px;
  border-bottom: 1px solid var(--toolbar-border);
  color: var(--fg-dim);
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-bottom: 4px;
}
#context-menu .ctx-item {
  padding: 5px 14px;
  cursor: pointer;
  color: var(--fg);
  white-space: nowrap;
}
#context-menu .ctx-item:hover {
  background: rgba(74, 158, 255, 0.18);
  color: var(--accent);
}
#context-menu .ctx-item.disabled {
  color: var(--fg-dim);
  cursor: default;
  pointer-events: none;
  opacity: 0.45;
}
#context-menu .ctx-sep {
  height: 1px;
  background: var(--toolbar-border);
  margin: 4px 0;
}

/* --- DSL extensions: icons, submenus, sections, grids ---
   See web/context-menu.js for the item-shape DSL.  Each extension
   reuses the base .ctx-item visual; the additional CSS rules below
   only add structural layout (flex for icon, absolute-positioned
   submenu, CSS-grid for multi-column rows). */

/* Icons sit before the label; color overridden inline per item. */
#context-menu .ctx-icon {
  display: inline-block;
  width: 14px;
  margin-right: 8px;
  text-align: center;
  font-weight: 600;
  /* Default icon color follows the foreground; per-item color
     style overrides this so axis pickers, palette swatches, status
     dots all read at-a-glance. */
}
#context-menu .ctx-item, #context-menu .ctx-cell {
  display: flex;
  align-items: center;
}
#context-menu .ctx-label { flex: 1 1 auto; }

/* Submenu rows — chevron on the right, child popup slides out
   horizontally on hover.  The child is positioned absolutely so it
   doesn't push the parent menu's layout. */
#context-menu .ctx-submenu-row {
  position: relative;
}
#context-menu .ctx-submenu-arrow {
  margin-left: 12px;
  opacity: 0.7;
  font-size: 10px;
}
#context-menu .ctx-submenu {
  position: absolute;
  top: -4px;
  left: 100%;
  min-width: 180px;
  background: rgba(12, 12, 20, 0.96);
  border: 1px solid var(--accent);
  border-radius: 4px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
  padding: 4px 0;
  backdrop-filter: blur(4px);
  display: none;
}
#context-menu .ctx-submenu-row:hover > .ctx-submenu {
  display: block;
}

/* Section block — bordered group with optional title.  Reads as
   a logical cluster within the menu without depending on the
   neighbour separators. */
#context-menu .ctx-section {
  margin: 4px 6px;
  padding: 4px 0;
  border: 1px solid var(--toolbar-border);
  border-radius: 3px;
}
#context-menu .ctx-section-title {
  padding: 2px 8px 4px;
  color: var(--fg-dim);
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

/* Multi-column grid — `grid-template-columns` is set inline per
   instance (the renderer computes `repeat(N, 1fr)`).  Cells inherit
   the .ctx-item visual + flex alignment.  Spans use CSS grid's
   span keyword, set inline by the renderer when cell.colspan /
   rowspan > 1. */
#context-menu .ctx-grid {
  display: grid;
  gap: 2px;
  padding: 2px 6px;
}
#context-menu .ctx-grid-cell {
  padding: 4px 6px;
  cursor: pointer;
  color: var(--fg);
  border: 1px solid transparent;
  border-radius: 3px;
  justify-content: center;
  white-space: nowrap;
}
#context-menu .ctx-grid-cell:hover {
  background: rgba(74, 158, 255, 0.18);
  color: var(--accent);
  border-color: var(--toolbar-border);
}


/* === Agent history popover === */
#agent-history-popover.popover {
  position: fixed;
  z-index: 10001;
  min-width: 320px;
  max-width: 520px;
  max-height: 60vh;
  overflow-y: auto;
  background: rgba(12, 12, 20, 0.96);
  border: 1px solid var(--accent);
  border-radius: 4px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
  padding: 8px 10px;
  backdrop-filter: blur(4px);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 11px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
#agent-history-popover[hidden] { display: none; }
#agent-history-popover .ah-header {
  color: var(--fg);
  font-size: 11px;
  letter-spacing: 0.04em;
  border-bottom: 1px solid var(--toolbar-border);
  padding-bottom: 4px;
}
#agent-history-popover .ah-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
#agent-history-popover .ah-row {
  border: 1px solid var(--toolbar-border);
  border-radius: 3px;
  padding: 4px 6px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
#agent-history-popover .ah-summary {
  color: var(--accent);
  font-size: 10px;
  letter-spacing: 0.04em;
}
#agent-history-popover .ah-children {
  color: var(--fg-dim);
  font-size: 10px;
  white-space: normal;
  word-break: break-word;
}
#agent-history-popover .ah-empty {
  color: var(--fg-dim);
  font-style: italic;
}

/* === Tool chips (finger/mouse Place + Link) ===
   Sit at the right end of the seed-strip row.  Single-character glyph,
   teal accent border when active.  Mutually exclusive — tvg3.js clears
   the other when one is engaged. */
.tool-chips {
  display: flex;
  gap: 4px;
  flex: 0 0 auto;
}
.tool-chip {
  background: rgba(0, 0, 0, 0.45);
  color: var(--fg-dim);
  border: 1px solid var(--toolbar-border);
  border-radius: 3px;
  width: 28px; height: 28px;
  font: inherit; font-size: 14px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  user-select: none;
  -webkit-user-select: none;
}
.tool-chip:hover { color: var(--fg); border-color: var(--accent); }
.tool-chip.active {
  background: var(--accent);
  border-color: var(--accent);
  color: #001220;
}

/* In-place tensor label editor.  The .tensor-label-text element
   becomes contenteditable; an outline + tinted background read as an
   editable field while the label keeps its world-pinned position.
   The wrapper override is critical: body has `user-select: none`,
   which Chromium propagates down such that keyboard input into the
   contenteditable is silently dropped even when the inner has
   `user-select: text` set.  Re-enabling text selection on the parent
   chain is what lets keystrokes actually insert characters. */
/* While ANY tensor label is being edited, lift `user-select: none`
   on the entire CSS2D label layer + body — Chromium drops keystrokes
   into a contenteditable when ANCESTOR (not just self) is non-
   selectable, regardless of self's `user-select: text`.  Body class
   is the simplest reliable cascade root. */
body.tensor-label-editing,
body.tensor-label-editing .label-layer,
body.tensor-label-editing .label-layer *,
body.tensor-label-editing .tensor-label,
body.tensor-label-editing .tensor-label * {
  user-select: text !important;
  -webkit-user-select: text !important;
}
.tensor-label.tensor-label--editing,
.tensor-label.tensor-label--editing * {
  pointer-events: auto;
}
/* Editing wrap MUST be visible regardless of label-mode (off / auto)
   or importance-thresholding opacity writes — the chief presses
   ENTER on an orb expecting a visible text input; if the label's
   per-frame opacity write is 0 from importance-thresholding, the
   contenteditable is technically there but invisible.  These rules
   beat every per-frame mutation: display, visibility, opacity all
   forced.  Chief 2026-05-25. */
.tensor-label.tensor-label--editing {
  display: block !important;
  visibility: visible !important;
  opacity: 1 !important;
}
.tensor-label.tensor-label--editing .tensor-label-text {
  display: block !important;
  visibility: visible !important;
  opacity: 1 !important;
  /* updateLabels writes a per-frame transform with scale(<refR/28>) for
     the zoom-fade.  At small zooms scale drops to 0.55 → rename box
     visually 33×8 px = unusable.  Force scale 1 while editing so the
     box stays readable.  translate stays so the box still anchors
     above the glyph. */
  transform: translate(-50%, -100%) scale(1) !important;
  /* And don't let opacity-on-collision dim the editing box. */
  min-width: 120px !important;
  font-size: 16px !important;
}
.tensor-label-text.editing {
  outline: 1px solid var(--accent);
  background: rgba(0, 0, 0, 0.65);
  border-radius: 2px;
  padding: 2px 4px;
  cursor: text;
  caret-color: var(--accent);
  user-select: text;
  -webkit-user-select: text;
  min-width: 60px;
}

/* Inline rename of the active file row.  The label flips into a
   contenteditable; the underline + accent box reads as an input
   without changing layout. */
.picker-item__label.editing {
  outline: 1px solid var(--accent);
  background: rgba(0, 0, 0, 0.45);
  border-radius: 2px;
  padding: 0 2px;
  cursor: text;
  caret-color: var(--accent);
}

/* === Savepoints popover (dropdown under Save button) === */
#savepoints-menu.popover {
  position: fixed;
  z-index: 10001;
  min-width: 220px;
  max-height: 380px;
  overflow-y: auto;
  background: rgba(12, 12, 20, 0.96);
  border: 1px solid var(--accent);
  border-radius: 4px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
  padding: 4px 0;
  backdrop-filter: blur(4px);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 11px;
}
#savepoints-menu.popover[hidden] { display: none; }
#savepoints-menu .sp-header {
  padding: 4px 10px 6px;
  border-bottom: 1px solid var(--toolbar-border);
  color: var(--fg-dim);
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-bottom: 4px;
}
#savepoints-menu .sp-item {
  padding: 5px 14px;
  cursor: pointer;
  color: var(--fg);
  white-space: nowrap;
  display: flex;
  justify-content: space-between;
  gap: 12px;
}
#savepoints-menu .sp-item:hover {
  background: rgba(74, 158, 255, 0.18);
  color: var(--accent);
}
#savepoints-menu .sp-item.disabled {
  color: var(--fg-dim);
  cursor: default;
  pointer-events: none;
  opacity: 0.45;
}
#savepoints-menu .sp-ts { font-variant-numeric: tabular-nums; flex: 0 0 auto; }
#savepoints-menu .sp-label {
  flex: 1 1 auto;
  margin-left: 8px;
  font-size: 10px;
  color: var(--accent);
  opacity: 0.85;
  font-style: italic;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#savepoints-menu .sp-action {
  color: var(--fg-dim);
  font-size: 10px;
  padding: 2px 6px;
  border-radius: 3px;
  cursor: pointer;
  flex: 0 0 auto;
}
#savepoints-menu .sp-action:hover {
  background: rgba(74, 158, 255, 0.28);
  color: var(--accent);
}
#savepoints-menu .sp-saveto { color: #d4a85a; }
#savepoints-menu .sp-saveto:hover { background: rgba(212, 168, 90, 0.28); color: #ffcf78; }

/* === Scrollbars === */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--toolbar-border); border-radius: 2px; }

/* === Dive breadcrumb (microscope navigation path) === */
#dive-breadcrumb {
  position: absolute;
  top: 10px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 5px 12px;
  background: rgba(12, 12, 20, 0.82);
  border: 1px solid var(--toolbar-border);
  border-radius: 4px;
  font-size: 11px;
  color: var(--fg);
  z-index: 10;
  backdrop-filter: blur(4px);
  pointer-events: auto;
}

#dive-breadcrumb.hidden { display: none; }

.crumb {
  color: var(--accent);
  cursor: pointer;
  padding: 2px 6px;
  border-radius: 3px;
  transition: background 0.12s, color 0.12s;
}
.crumb:hover {
  background: rgba(74, 158, 255, 0.18);
}
.crumb.current {
  color: #fff;
  cursor: default;
}
.crumb.current:hover { background: transparent; }

.crumb-sep {
  color: var(--fg-dim);
  font-size: 10px;
  user-select: none;
}

/* === Sky indicators (exterior bonds visible from inside a dive) === */
.sky-label {
  font-style: italic;
  color: #667799;
  font-size: 10px;
  pointer-events: none;
  white-space: nowrap;
}

/* === Disabled granularity slider during dive === */
#gran-slider:disabled {
  opacity: 0.3;
  cursor: not-allowed;
}
#gran-slider:disabled::-webkit-slider-thumb {
  cursor: not-allowed;
  background: var(--fg-dim);
  box-shadow: none;
}
#gran-slider:disabled::-moz-range-thumb {
  cursor: not-allowed;
  background: var(--fg-dim);
  box-shadow: none;
}

/* === Mobile vs desktop ===
   The hamburger and drawer are the same in both modes — only the
   side/bottom panels and the overlay sizes change.  Mobile = content-
   first, no panels, seed strip + slider pinned full-width.  Desktop =
   panels visible, overlays centered. */
body.mobile-ui #app {
  grid-template-rows: 0 1fr 0 0;
}
body.mobile-ui .panel:not(.panel-bottom),
body.mobile-ui .panel-bottom,
body.mobile-ui .gutter {
  visibility: hidden;
  pointer-events: none;
}
/* Mobile: timeline shrinks but stays visible — narrow viewport tunes
   font-size and padding so the strip fits a phone width.  Same wheel-
   pass-through trick (set in the base #timeline-wrap rule) applies. */
body.mobile-ui #timeline-wrap {
  font-size: 9px;
  padding: 4px 6px 3px 6px;
}
/* Fullscreen button stays visible in the drawer in mobile-ui mode.
   Desktop browsers in mobile-ui get a working requestFullscreen;
   iOS Safari's API call fails silently inside the try/catch and the
   button is a no-op (no platform popup, no error toast).  The chief
   wants it present in the drawer regardless. */

body.mobile-ui #agent-seed-strip {
  /* Inside #viewport-top-seed-row flex; takes all space left over by
     the hamburger (which sits to its right via flex order).  Hiding
     this with display:none leaves the hamburger correctly aligned;
     hiding the hamburger leaves this filling the row width. */
  position: static;
  flex: 1 1 auto;
  transform: none;
  min-width: 0;
  max-width: none;
  width: auto;
  padding: 8px 10px;
  gap: 8px;
}
body.mobile-ui #agent-seed-input {
  min-height: 36px;
  padding: 8px 10px;
  font-size: 14px;
}
body.mobile-ui #agent-seed-btn {
  min-height: 36px;
  min-width: 54px;
  padding: 8px 12px;
  font-size: 14px;
}
/* Projection slider — horizontal, pinned below the seed strip.
   [2D] [=====slider=====] [3D] [☐ Hyp].  The 2D / 3D end-buttons are
   fat touch targets that snap the slider to the corresponding extreme
   when tapped; the hyperbolic chip is its own distinct control (it
   used to nest inside the 2D button, which made the outer button
   impossible to hit). */
body.mobile-ui #proj-slider-wrap {
  /* Inside #viewport-top flex column; flows under the seed row.  No
     absolute positioning: the row's width and vertical position are
     given by the parent flex.  Hiding the seed row above (via
     display:none) collapses the gap cleanly. */
  position: static;
  transform: none;
  left: auto; right: auto; top: auto; bottom: auto;
  width: 100%;
  height: auto;
  padding: 6px 10px;
  display: flex;
  flex-direction: row;
  align-items: center;
  box-sizing: border-box;
  overflow: visible;
  gap: 0;
  cursor: pointer;
  touch-action: none;
}
body.mobile-ui .slider-row {
  display: flex;
  flex-direction: row;
  align-items: center;                 /* harmonised vertical alignment */
  justify-content: stretch;
  width: 100%;
  height: auto;
  gap: 8px;
  padding: 0;
  margin: 0;
  min-height: 44px;                    /* row height matches the buttons */
}
/* Bottom-centre toast — disappears automatically.  Used for modal-
   less feedback on gestures whose UI target would be hidden by the
   user's finger (e.g., long-press-2D to toggle Hyperbolic).  Bottom
   position keeps it out of the thumb zone at the top of the screen. */
#toast {
  position: fixed;
  left: 50%;
  bottom: 12%;
  transform: translate(-50%, 0);
  max-width: 80vw;
  padding: 10px 18px;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 14px;
  font-weight: 600;
  color: #fff;
  background: rgba(18, 18, 26, 0.94);
  border: 1px solid var(--accent);
  border-radius: 8px;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.55);
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.18s ease-out, transform 0.18s ease-out;
  z-index: 10050;
  white-space: nowrap;
}
#toast.show {
  opacity: 1;
  transform: translate(-50%, -8px);
}
#toast .toast-on  { color: var(--accent); }
#toast .toast-off { color: var(--fg-dim); }
body.mobile-ui #proj-slider-wrap #proj-slider {
  /* Horizontal slider — reset any earlier vertical rules. */
  -webkit-appearance: none;
  appearance: none;
  writing-mode: horizontal-tb;
  direction: ltr;
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  width: auto;
  height: 12px;
  padding: 0;
  background: var(--toolbar-border);
  border-radius: 6px;
}
body.mobile-ui #proj-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--fg);
  cursor: pointer;
}
body.mobile-ui #proj-slider::-moz-range-thumb {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--fg);
  cursor: pointer;
}
body.mobile-ui .proj-title,
body.mobile-ui #proj-value {
  display: none;  /* titles + numeric readout are superfluous on touch */
}
/* 2D / 3D end-buttons: fat touch targets, easy to hit. */
body.mobile-ui #proj-slider-wrap .proj-toggle {
  flex: 0 0 auto;
  min-width: 52px;
  min-height: 44px;
  padding: 6px 12px;
  font-size: 15px;
  font-weight: 600;
  line-height: 1;
  border: 1px solid var(--toolbar-border);
  border-radius: 6px;
  background: rgba(74, 158, 255, 0.08);
  color: var(--fg);
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation;
}
body.mobile-ui #proj-slider-wrap .proj-toggle.active {
  background: var(--accent);
  color: var(--bg);
  border-color: var(--accent);
}
/* Hyperbolic chip — separate from the 2D button so the user can
   reliably hit either one without overlap. */
body.mobile-ui #proj-slider-wrap .proj-hyperbolic-toggle {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 44px;
  padding: 6px 10px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.04em;
  border: 1px solid var(--toolbar-border);
  border-radius: 6px;
  background: rgba(74, 158, 255, 0.06);
  color: var(--fg);
  text-transform: none;
  user-select: none;
  -webkit-user-select: none;
}
body.mobile-ui #proj-slider-wrap .proj-hyperbolic-toggle input[type="checkbox"] {
  width: 22px;
  height: 22px;
  margin: 0;
  accent-color: var(--accent);
  cursor: pointer;
}

/* On desktop the toolbar grid row is gone — the drawer is fixed
   position and doesn't participate in the grid. */
#app {
  grid-template-rows: 0 1fr var(--gutter-h) var(--bottom-h);
}

/* Boot placeholder — covers the viewport before JS runs and stays
   until the bundle removes it on first render.  Structural
   guarantee that the window is never empty.  Pointer-events off so
   the toolbar (which renders BEFORE this in document order but is
   above z-index-wise via #app stacking) remains clickable; turned
   ON by the failsafe when it repurposes the placeholder as a
   click-to-retry surface. */
#boot-placeholder {
  position: fixed;
  inset: 0;
  z-index: 5;
  background: radial-gradient(ellipse at center, #0c1424 0%, #04060c 100%);
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  font-family: ui-monospace, "JetBrains Mono", monospace;
  user-select: none;
  transition: opacity 200ms ease-out;
}
#boot-placeholder .boot-content { text-align: center; }
#boot-placeholder .boot-title {
  font-size: 36px;
  letter-spacing: 8px;
  color: #4a9eff;
  margin-bottom: 12px;
  font-weight: 200;
}
#boot-placeholder .boot-status {
  font-size: 13px;
  color: #6c7a8a;
}
#boot-placeholder.fade { opacity: 0; }

/* Spectator mode (no edit, no broadcast) — hide the seed input
   and the 2D/3D projection slider.  Keep #viewport-top-seed-row
   itself visible: the hamburger is moved into it on init, so hiding
   the row would remove the menu button too. */
body.spectator-mode #agent-seed-strip,
body.spectator-mode #tool-chips,
body.spectator-mode #timeline-wrap {
  display: none !important;
}
/* The 2D↔3D slider in spectator mode is a personal viewing choice
   (ortho ↔ persp blend, the spectator's own camera) — never a
   mutation of the broadcaster's state.  Visible by default for
   spectators, but HIDDEN while they're actively joined to a live
   broadcast (bc-joined): the broadcaster's projection drives the
   view in that mode, and a slider that visibly fights it would
   read as a broken control.  When the spectator Leaves the
   broadcast (bc-joined drops) the slider returns. */
body.spectator-mode.bc-joined #proj-slider-wrap {
  display: none !important;
}
/* In spectator mode renderBroadcastUI moves #proj-slider-wrap into
   #viewport-top-seed-row alongside the hamburger.  When it lives
   there it must flex-grow to fill the leftover horizontal space —
   the hamburger holds its right-edge anchor via its existing
   margin-left:auto.  Outside spectator mode the slider goes back
   to its own row inside #viewport-top and the rule below stops
   matching, so its native column-shape comes back. */
body.spectator-mode #viewport-top-seed-row > #proj-slider-wrap {
  flex: 1 1 auto;
  min-width: 0;
}

/* Spectator drawer: simplified — only the file picker, Demo, and
   Fullscreen are exposed.  Edit/undo/redo/save/savepoints, view-toggles,
   layout chips, broadcast pill all hidden.  Broadcast-capable users
   keep the rich drawer (broadcast button, edit, etc) — these rules
   match only when body has spectator-mode. */
/* Spectator drawer hides authoring controls.  Broadcast pill is
   intentionally LEFT IN — spectators with an active broadcast use it
   for LEAVE / REJOIN.  renderBroadcastUI controls its visibility via
   the .hidden attribute (no broadcast active → hidden). */
body.spectator-mode #btn-new,
body.spectator-mode #btn-edit-mode,
body.spectator-mode #btn-pen-mode,
body.spectator-mode #btn-undo,
body.spectator-mode #btn-redo,
body.spectator-mode .btn-pair,
body.spectator-mode #savepoints-menu,
body.spectator-mode #btn-invert-rotate,
body.spectator-mode #btn-spacemouse,
body.spectator-mode #btn-invert-wheel,
body.spectator-mode #btn-show-hidden,
body.spectator-mode #btn-script-cards,
body.spectator-mode #btn-force-refresh,
body.spectator-mode .picker-item__more,
body.spectator-mode .picker-item__del,
body.spectator-mode #poincare2-params {
  display: none !important;
}

/* === Crop modal — touch-first manual crop with drag handles === */
#crop-modal {
  position: fixed; inset: 0;
  z-index: 9000;
  display: flex; align-items: center; justify-content: center;
  flex-direction: column;
  background: rgba(0, 0, 0, 0.92);
  touch-action: none;          /* JS owns every gesture */
  user-select: none;
  -webkit-user-select: none;
}
#crop-modal[hidden] { display: none; }
#crop-backdrop {
  position: absolute; inset: 0;
  pointer-events: none;
}
#crop-stage {
  position: relative;
  /* Width/height set by JS to match the displayed image. */
}
#crop-canvas {
  display: block;
  background: #1a1a22;
  border: 1px solid rgba(255,255,255,0.08);
  /* Fit-to-screen; JS sets width/height. */
}
#crop-box {
  position: absolute;
  box-sizing: border-box;
  border: 2px solid #4a9eff;
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55);  /* dim outside the box */
  cursor: move;
  pointer-events: auto;
}
.crop-handle {
  position: absolute;
  width: 24px; height: 24px;
  background: #4a9eff;
  border: 2px solid #fff;
  border-radius: 50%;
  box-sizing: border-box;
  touch-action: none;
}
.crop-handle.nw { top: -12px; left: -12px;            cursor: nwse-resize; }
.crop-handle.n  { top: -12px; left: 50%;
                  transform: translateX(-50%);        cursor: ns-resize;  }
.crop-handle.ne { top: -12px; right: -12px;           cursor: nesw-resize;}
.crop-handle.e  { top: 50%;   right: -12px;
                  transform: translateY(-50%);        cursor: ew-resize;  }
.crop-handle.se { bottom: -12px; right: -12px;        cursor: nwse-resize;}
.crop-handle.s  { bottom: -12px; left: 50%;
                  transform: translateX(-50%);        cursor: ns-resize;  }
.crop-handle.sw { bottom: -12px; left: -12px;         cursor: nesw-resize;}
.crop-handle.w  { top: 50%;   left: -12px;
                  transform: translateY(-50%);        cursor: ew-resize;  }
#crop-toolbar {
  position: absolute; bottom: 0; left: 0; right: 0;
  display: flex; align-items: center; justify-content: space-between;
  gap: 16px;
  padding: 12px 24px;
  background: rgba(20, 22, 32, 0.96);
  border-top: 1px solid rgba(255,255,255,0.08);
}
#crop-toolbar button {
  padding: 10px 20px;
  font: inherit; font-size: 14px;
  background: rgba(74, 158, 255, 0.18);
  color: #e0e6f0;
  border: 1px solid rgba(74, 158, 255, 0.55);
  border-radius: 5px;
  cursor: pointer;
  min-height: 44px;     /* iOS tap target */
}
#crop-toolbar button:hover { background: rgba(74, 158, 255, 0.32); }
#crop-toolbar #crop-cancel {
  background: rgba(180, 60, 60, 0.18);
  border-color: rgba(180, 60, 60, 0.55);
}
#crop-toolbar #crop-cancel:hover { background: rgba(180, 60, 60, 0.32); }
/* Active-toggle look for the Mirror H / Mirror V buttons — they
   carry on/off state, so the "engaged" pose needs to read.  Chief
   2026-05-25 image-edit recast. */
#crop-toolbar button.active {
  background: rgba(74, 158, 255, 0.55);
  border-color: rgba(74, 158, 255, 0.95);
  color: #fff;
}
#crop-readout {
  font-size: 12px;
  color: rgba(224, 230, 240, 0.7);
  font-variant-numeric: tabular-nums;
}

/* === Demo links panel — drawer section for mint/share/revoke === */
#demo-links-panel {
  margin: 12px 14px 0 14px;
  padding: 12px 14px;
  background: rgba(40, 50, 70, 0.32);
  border: 1px solid rgba(74, 158, 255, 0.22);
  border-radius: 6px;
  font-size: 13px;
  color: var(--fg);
}
#demo-links-panel[hidden] { display: none; }
.drawer-section-header {
  display: flex; align-items: baseline; justify-content: space-between;
  margin-bottom: 8px;
}
.drawer-section-title {
  font-weight: 600;
  letter-spacing: 0.04em;
  font-size: 12px;
  text-transform: uppercase;
}
.drawer-section-meta {
  font-size: 11px;
  color: rgba(224, 230, 240, 0.55);
  font-variant-numeric: tabular-nums;
}
.drawer-section-action {
  width: 100%;
  padding: 9px 12px;
  background: rgba(74, 158, 255, 0.18);
  color: var(--fg);
  border: 1px solid rgba(74, 158, 255, 0.50);
  border-radius: 4px;
  cursor: pointer;
  font: inherit; font-size: 13px;
  display: flex; align-items: center; justify-content: center; gap: 6px;
  min-height: 36px;
}
.drawer-section-action:hover { background: rgba(74, 158, 255, 0.32); }
.demo-links-fresh {
  margin-top: 10px;
  padding: 8px;
  background: rgba(80, 200, 120, 0.14);
  border: 1px solid rgba(80, 200, 120, 0.45);
  border-radius: 4px;
  display: flex; flex-direction: column; gap: 6px;
}
.demo-links-fresh[hidden] { display: none; }
.demo-links-fresh-label {
  font-size: 11px;
  color: rgba(80, 200, 120, 0.95);
  font-weight: 600;
}
.demo-links-fresh-url {
  font-family: ui-monospace, monospace;
  font-size: 11px;
  background: rgba(0, 0, 0, 0.45);
  color: var(--fg);
  border: 1px solid rgba(255,255,255,0.08);
  border-radius: 3px;
  padding: 6px 8px;
  width: 100%;
  user-select: all;
  -webkit-user-select: all;
}
.demo-links-copy {
  align-self: flex-end;
  padding: 4px 12px;
  font: inherit; font-size: 12px;
  background: rgba(80, 200, 120, 0.30);
  color: var(--fg);
  border: 1px solid rgba(80, 200, 120, 0.7);
  border-radius: 3px;
  cursor: pointer;
}
.demo-links-list {
  list-style: none;
  padding: 0; margin: 10px 0 0 0;
}
.demo-links-list li {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 0;
  border-top: 1px solid rgba(255,255,255,0.06);
  font-size: 12px;
}
.demo-links-list li:first-child { border-top: none; }
.demo-links-list .dl-token {
  flex: 1 1 auto;
  font-family: ui-monospace, monospace;
  font-size: 11px;
  color: rgba(224, 230, 240, 0.85);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.demo-links-list .dl-uses {
  font-size: 11px;
  color: rgba(74, 158, 255, 0.85);
  font-variant-numeric: tabular-nums;
  min-width: 3.5em; text-align: right;
}
.demo-links-list .dl-uses[data-active="0"] {
  color: rgba(224, 230, 240, 0.35);
}
.demo-links-list .dl-copy,
.demo-links-list .dl-revoke {
  padding: 3px 8px;
  font: inherit; font-size: 11px;
  border-radius: 3px;
  cursor: pointer;
  background: transparent;
  color: var(--fg);
  border: 1px solid rgba(255,255,255,0.18);
}
.demo-links-list .dl-copy:hover { background: rgba(74,158,255,0.18); }
.demo-links-list .dl-revoke {
  border-color: rgba(255, 100, 100, 0.45);
  color: rgba(255, 180, 180, 0.85);
}
.demo-links-list .dl-revoke:hover { background: rgba(255, 100, 100, 0.18); }

/* === Demo links — collapsible nested entry === */
#demo-links-panel.drawer-section--collapsible {
  margin: 8px 14px 0 14px;
  padding: 0;
  background: transparent;
  border: none;
}
.drawer-section-toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 8px 12px;
  background: rgba(40, 50, 70, 0.32);
  color: var(--fg);
  border: 1px solid rgba(74, 158, 255, 0.22);
  border-radius: 4px;
  cursor: pointer;
  font: inherit; font-size: 13px;
  text-align: left;
  min-height: 36px;
}
.drawer-section-toggle:hover {
  background: rgba(74, 158, 255, 0.18);
  border-color: rgba(74, 158, 255, 0.40);
}
.drawer-section-toggle .drawer-section-title {
  font-weight: 500;
  letter-spacing: 0.02em;
  text-transform: none;
}
.drawer-section-toggle .drawer-section-meta {
  margin-left: auto;
  margin-right: 4px;
  font-size: 11px;
  color: rgba(224, 230, 240, 0.55);
  font-variant-numeric: tabular-nums;
}
.drawer-section-chevron {
  display: inline-block;
  font-size: 12px;
  color: rgba(224, 230, 240, 0.55);
  transition: transform 0.18s ease;
}
.drawer-section-toggle[aria-expanded="true"] .drawer-section-chevron {
  transform: rotate(90deg);
}
.drawer-section-body {
  margin-top: 6px;
  padding: 10px 12px;
  background: rgba(40, 50, 70, 0.32);
  border: 1px solid rgba(74, 158, 255, 0.22);
  border-radius: 4px;
}
.drawer-section-body[hidden] { display: none; }

/* --- Demo-links — compact one-line affordance.  The section is
   secondary in the drawer hierarchy (master/demo-user only); it
   shouldn't dominate vertical space when collapsed. --- */
#demo-links-panel.drawer-section--collapsible {
  margin: 4px 14px 0 14px;
}
#demo-links-panel .drawer-section-toggle {
  padding: 4px 10px;
  min-height: 26px;
  font-size: 11px;
  letter-spacing: 0.04em;
  background: transparent;
  border-color: rgba(74, 158, 255, 0.14);
  color: var(--fg-dim);
}
#demo-links-panel .drawer-section-toggle:hover {
  background: rgba(74, 158, 255, 0.08);
  border-color: rgba(74, 158, 255, 0.28);
  color: var(--fg);
}
#demo-links-panel .drawer-section-title {
  font-size: 11px;
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
#demo-links-panel .drawer-section-meta {
  font-size: 10px;
}
#demo-links-panel .drawer-section-chevron {
  font-size: 10px;
}
#demo-links-panel .drawer-section-body {
  padding: 8px 10px;
  margin-top: 4px;
}

/* --- Icon library — each .icon-<name> sets a mask + colour.  The
   mask URL is a tiny inline SVG silhouette; colour is whatever the
   design says the icon means.  Editing an icon is a single-line
   change here; HTML stays generic. --- */
.icon-menu        { background-color: #8aa9d0;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'><path d='M4 6h16M4 12h16M4 18h16'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'><path d='M4 6h16M4 12h16M4 18h16'/></svg>");
}
.icon-home        { background-color: #2ed4c9;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M3 11l9-7 9 7'/><path d='M5 10v10h14V10'/><path d='M10 20v-6h4v6'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M3 11l9-7 9 7'/><path d='M5 10v10h14V10'/><path d='M10 20v-6h4v6'/></svg>");
}
.icon-play        { background-color: #ffa64d;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'><path d='M7 4v16l14-8z'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'><path d='M7 4v16l14-8z'/></svg>");
}
.icon-fullscreen  { background-color: #4a9eff;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M4 9V4h5'/><path d='M15 4h5v5'/><path d='M20 15v5h-5'/><path d='M9 20H4v-5'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M4 9V4h5'/><path d='M15 4h5v5'/><path d='M20 15v5h-5'/><path d='M9 20H4v-5'/></svg>");
}
.icon-refresh     { background-color: #ffcf78;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21 12a9 9 0 1 1-3-6.7'/><path d='M21 4v5h-5'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21 12a9 9 0 1 1-3-6.7'/><path d='M21 4v5h-5'/></svg>");
}
.icon-plus        { background-color: #6fd97f;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'><path d='M12 5v14M5 12h14'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'><path d='M12 5v14M5 12h14'/></svg>");
}
.icon-new         { background-color: #6fd97f;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z'/><path d='M14 3v5h5'/><path d='M12 12v6'/><path d='M9 15h6'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z'/><path d='M14 3v5h5'/><path d='M12 12v6'/><path d='M9 15h6'/></svg>");
}
.icon-edit        { background-color: #c89bff;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='8'/><path d='M12 2v4M12 18v4M2 12h4M18 12h4'/><circle cx='12' cy='12' r='1.2' fill='black' stroke='none'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='8'/><path d='M12 2v4M12 18v4M2 12h4M18 12h4'/><circle cx='12' cy='12' r='1.2' fill='black' stroke='none'/></svg>");
}
.icon-pen         { background-color: #c89bff;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M4 20l4-1L19 8l-3-3L5 16z'/><path d='M14 6l3 3'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M4 20l4-1L19 8l-3-3L5 16z'/><path d='M14 6l3 3'/></svg>");
}
.icon-undo        { background-color: #8aa9d0;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M9 14l-5-5 5-5'/><path d='M4 9h11a5 5 0 0 1 0 10h-3'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M9 14l-5-5 5-5'/><path d='M4 9h11a5 5 0 0 1 0 10h-3'/></svg>");
}
.icon-redo        { background-color: #8aa9d0;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M15 14l5-5-5-5'/><path d='M20 9H9a5 5 0 0 0 0 10h3'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M15 14l5-5-5-5'/><path d='M20 9H9a5 5 0 0 0 0 10h3'/></svg>");
}
.icon-save        { background-color: #4a9eff;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M5 4h11l4 4v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z'/><path d='M7 4v6h9V4'/><path d='M7 14h9v6H7z'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M5 4h11l4 4v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z'/><path d='M7 4v6h9V4'/><path d='M7 14h9v6H7z'/></svg>");
}
.icon-chevron-down{ background-color: #8aa9d0; width: 12px; height: 12px;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>");
}
.icon-rotate      { background-color: #6fdfe6;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21 12a9 9 0 1 1-3-6.7'/><path d='M21 3v5h-5'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21 12a9 9 0 1 1-3-6.7'/><path d='M21 3v5h-5'/></svg>");
}
.icon-axes        { background-color: #c89bff;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M12 3v18'/><path d='M3 12h18'/><path d='M5 19l14-14'/><circle cx='12' cy='12' r='2.5' fill='black'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M12 3v18'/><path d='M3 12h18'/><path d='M5 19l14-14'/><circle cx='12' cy='12' r='2.5' fill='black'/></svg>");
}
.icon-arrows-vert { background-color: #6fdfe6;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M8 7l-4-4-4 4' transform='translate(8 0)'/><path d='M8 17l-4 4-4-4' transform='translate(8 0)'/><path d='M12 3v18'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M8 7l-4-4-4 4' transform='translate(8 0)'/><path d='M8 17l-4 4-4-4' transform='translate(8 0)'/><path d='M12 3v18'/></svg>");
}
.icon-eye         { background-color: #6fd97f;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z'/><circle cx='12' cy='12' r='3'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z'/><circle cx='12' cy='12' r='3'/></svg>");
}
.icon-depth       { background-color: #8aa9d0;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'><circle cx='12' cy='12' r='9'/><path d='M12 3a9 9 0 0 0 0 18z' fill='black'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'><circle cx='12' cy='12' r='9'/><path d='M12 3a9 9 0 0 0 0 18z' fill='black'/></svg>");
}
.icon-sigma       { background-color: #ffcf78;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M5 4h13v3'/><path d='M5 4l8 8-8 8'/><path d='M5 20h13v-3'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M5 4h13v3'/><path d='M5 4l8 8-8 8'/><path d='M5 20h13v-3'/></svg>");
}
.icon-infinity    { background-color: #c89bff;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M7 12c0-3 2-5 4-5s3 2 5 5 3 5 5 5 3-2 0-5c-3-3-3-5-5-5s-3 2-5 5-2 5-4 5-3-2 0-5z'/></svg>");
  mask-image:         url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M7 12c0-3 2-5 4-5s3 2 5 5 3 5 5 5 3-2 0-5c-3-3-3-5-5-5s-3 2-5 5-2 5-4 5-3-2 0-5z'/></svg>");
}

/* Rig labels (spacecraft demo: north / south / equator / vernal eq. /
   periapsis / apoapsis / Earth / spacecraft).  CSS2DRenderer pins the
   wrapper at a world point; the inner label is offset and styled
   distinctly from tensor labels so the static-rig annotation reads
   as scenery, not state. */
.rig-label-wrap {
  pointer-events: auto;
  position: absolute;
  width: 0; height: 0;
}
.rig-label {
  position: absolute;
  left: 6px; top: -8px;
  white-space: nowrap;
  font-family: var(--font-system, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
  font-size: 11px;
  color: #cfe6ff;
  background: rgba(20, 30, 50, 0.55);
  border: 1px solid rgba(120, 160, 220, 0.35);
  border-radius: 3px;
  padding: 1px 5px;
  letter-spacing: 0.02em;
  text-shadow: 0 1px 2px rgba(0,0,0,0.6);
  cursor: help;
}
.rig-label:hover {
  background: rgba(40, 60, 90, 0.85);
  color: #ffffff;
  border-color: rgba(160, 200, 255, 0.6);
}

/* tvg3-overlay-layer ------------------------------------------------
 * Every screen-space DOM element that gets its position re-written
 * each frame (CSS2DRenderer labels, selection handles, mohr theta
 * readouts, future per-frame overlays) MUST carry this class.
 *
 * The two declarations are not cosmetic — they tell the compositor:
 *   will-change:transform   — promote the element to its own GPU
 *                             layer up front, so per-frame transform
 *                             writes are pure layer-moves, not heap
 *                             of partial repaints.
 *   contain:layout style paint
 *                           — scope reflow/repaint to this element's
 *                             box, so a per-frame transform change
 *                             can't dirty unrelated regions.
 *
 * Without these, Chromium and Firefox both leave ghost trails of
 * the previous frame's pixels under moving overlays — first surfaced
 * on the selection handles (chief 2026-05-24) and tracked into the
 * mohr-3D theta value labels.  The class IS the fix; use it for any
 * new per-frame overlay so this regression doesn't have to be
 * relearned at each call site.
 *
 * Helper: scene.js exports overlayDiv() that returns a div already
 * carrying this class.  Prefer the helper; the raw class is a fall-
 * back for elements created by libraries we don't own (e.g.,
 * CSS2DObject's wrap element gets the class applied after construction).
 */
.tvg3-overlay-layer {
  will-change: transform;
  contain: layout style paint;
}
