/* WebSL control deck — shared tokens + components.
   Design direction: docs/slice3/DESIGN.md. Read that first if you're about
   to add a color or a font that isn't already named here — the palette is
   deliberately closed (6 hexes) and each one has a job. */

@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400..800&family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');

:root {
  color-scheme: dark;

  /* ---- palette (docs/slice3/DESIGN.md §1) ---- */
  --deep-water: #0a1420;
  --undertow: #12202f;
  --undertow-2: #0e1a26;
  --foam: #e9f1f3;
  --foam-60: rgba(233, 241, 243, 0.6);
  --foam-35: rgba(233, 241, 243, 0.35);
  --foam-15: rgba(233, 241, 243, 0.15);
  --dock-lamp: #e8a23b;
  --dock-lamp-dim: rgba(232, 162, 59, 0.18);
  --moonwake: #7fd8cc;
  --moonwake-dim: rgba(127, 216, 204, 0.18);
  --flare: #ff6e5a;
  --flare-dim: rgba(255, 110, 90, 0.18);

  --font-display: 'Bricolage Grotesque', ui-sans-serif, system-ui, sans-serif;
  --font-body: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
  --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', monospace;

  --radius-sm: 8px;
  --radius-md: 14px;
  --radius-lg: 22px;

  --safe-t: env(safe-area-inset-top, 0px);
  --safe-b: env(safe-area-inset-bottom, 0px);
  --safe-l: env(safe-area-inset-left, 0px);
  --safe-r: env(safe-area-inset-right, 0px);

  --dur: 0.22s;
  --ease: cubic-bezier(0.2, 0.7, 0.3, 1);

  /* ---- layering scale (docs/slice3/DESIGN.md "Layering & spacing system")
     One ascending scale for every stacking context in this file, named by
     ROLE rather than by the element that happens to use it today, so the
     next addition picks a tier instead of a fresh number. Gaps between
     tiers are deliberate headroom, not padding to fill. */
  --z-panel: 20;        /* tab sheet content panel */
  --z-fslayer: 22;       /* fullscreen 3D overlay container */
  --z-touch: 24;         /* in-stage feedback: touch move/look zones, click crosshair */
  --z-fab: 25;           /* deck corner fabs: menu/keyboard/fullscreen toggles */
  --z-fschrome: 26;      /* fullscreen corner chrome: exit/fly/camera/UI-toggle/status */
  --z-tapgate: 30;       /* tap-to-start scrim -- blocks everything until dismissed */
  --z-statusstrip: 40;   /* deck status strip */
  --z-tabbar: 45;        /* tab bar -- always reachable over an open sheet */
  --z-stage-fs: 50;      /* fullscreen stage / admin modal overlay */
  --z-toast: 60;         /* IM arrival toast, admin toast */
  --z-toast-urgent: 65;  /* input-reliability toast -- outranks the IM toast */
  --z-conduit: 70;       /* text-input conduit bar -- wins while actively typing */

  /* ---- fullscreen top-chrome spacing (docs/slice3/DESIGN.md) -- each row
     below is DERIVED from the row above's real height + --fs-row-gap, not
     an independently-guessed pixel offset. This is what the overlap fix
     actually is: not new numbers, but numbers that admit they depend on
     each other. --fs-row1-h is #fsExit's real height (42px), the tallest
     of the three row-1 elements (fsExit/fsFlyToggle/fsStatus). */
  --fs-row-gap: 10px;
  --fs-row1-h: 42px;
  --fs-row2-h: 30px;     /* .fsCamRow's real pill height */
  --fs-row1-bottom: calc(14px + var(--fs-row1-h));
  --fs-row2-top: calc(var(--fs-row1-bottom) + var(--fs-row-gap));
  --fs-row2-bottom: calc(var(--fs-row2-top) + var(--fs-row2-h));
  --fs-row3-top: calc(var(--fs-row2-bottom) + var(--fs-row-gap));
  /* fsUiToggle can wrap to two lines (long "Menus hidden/shown -- ..."
     copy) -- 46px is that worst case, not its common single-line height,
     specifically so the touch-zone exclusion band below never has to
     re-guess this the way the original 84px guess did. */
  --fs-row3-max-h: 46px;
  --fs-row3-max-bottom: calc(var(--fs-row3-top) + var(--fs-row3-max-h));
}

@media (prefers-reduced-motion: reduce) {
  :root { --dur: 0s; }
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  height: 100%;
  background: var(--deep-water);
  color: var(--foam);
  font-family: var(--font-body);
  -webkit-tap-highlight-color: transparent;
}

body {
  overscroll-behavior: none;
}

button, input {
  font-family: inherit;
  color: inherit;
}

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

:focus-visible {
  outline: 2px solid var(--moonwake);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

.wordmark {
  font-family: var(--font-display);
  font-weight: 700;
  letter-spacing: 0.01em;
}

.mono {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

/* ---------------------------------------------------------------- ripple
   The one signature element (docs/slice3/DESIGN.md §2). Concentric rings
   expanding from a point — water ring / presence pulse. Used in exactly
   five places: tap-to-start icon, connection-status dot, teleport
   hold-to-confirm fill, fullscreen joystick/touch-move-stick idle glow,
   and (docs/slice3/MOBILE-CONTROLS.md) the one-shot materialize ping on
   the touch look-pad — same signature reused for "a thumb just landed
   here," not a new animated idea. Color comes from --ripple-color, set
   per-usage. */
.ripple {
  --ripple-color: var(--dock-lamp);
  position: relative;
  width: 1em;
  height: 1em;
  display: inline-block;
}
.ripple .ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 1.5px solid var(--ripple-color);
  opacity: 0;
  animation: ripple-expand 2.2s var(--ease) infinite;
}
.ripple .ring:nth-child(2) { animation-delay: 0.55s; }
.ripple .ring:nth-child(3) { animation-delay: 1.1s; }
.ripple .core {
  position: absolute;
  inset: 30%;
  border-radius: 50%;
  background: var(--ripple-color);
}
@keyframes ripple-expand {
  0% { transform: scale(0.4); opacity: 0.9; }
  100% { transform: scale(2.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .ripple .ring { animation: none; opacity: 0.55; transform: none; }
  .ripple .ring:nth-child(2), .ripple .ring:nth-child(3) { display: none; }
}

/* ------------------------------------------------------------ status strip
   Top-center, full-width, translucent — deliberately not a corner badge.
   See DESIGN.md self-critique for why. */
#statusStrip {
  position: fixed;
  top: var(--safe-t);
  left: 0;
  right: 0;
  z-index: var(--z-statusstrip);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5em;
  padding: 0.55em 1em;
  font-size: 12.5px;
  color: var(--foam-60);
  background: linear-gradient(to bottom, rgba(10, 20, 32, 0.75), rgba(10, 20, 32, 0));
  pointer-events: none;
  text-align: center;
  transition: opacity var(--dur) var(--ease);
}
#statusStrip[data-state="live"] { opacity: 0.55; }
#statusStrip[data-state="error"],
#statusStrip[data-state="reconnecting"] { color: var(--foam); }
#statusStrip .ripple { font-size: 9px; flex: none; }
#statusStrip[data-state="connecting"] .ripple,
#statusStrip[data-state="no-publisher"] .ripple { --ripple-color: var(--dock-lamp); }
#statusStrip[data-state="live"] .ripple { --ripple-color: var(--moonwake); }
#statusStrip[data-state="reconnecting"] .ripple,
#statusStrip[data-state="error"] .ripple { --ripple-color: var(--flare); }
#statusStrip #statusText { max-width: 88vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* -------------------------------------------------------------------- app */
#app {
  position: fixed;
  inset: 0;
  display: flex;
  flex-direction: column;
  background: var(--deep-water);
}

#stage {
  position: relative;
  flex: 1;
  min-height: 0;
  background: #000;
  overflow: hidden;
}

#stage video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* REGRESSION FIX (2026-08-04, docs/slice3/REGRESSION-2026-08-04.md): `contain`
     letterboxed the world inside big black bars (the capture's native aspect --
     a desktop viewer window -- is far wider than a phone viewport). `cover`
     fills the stage edge to edge, cropping the long edges instead of blanking
     them; app.js's click handler compensates for that crop when mapping a
     click into world coordinates (see the `object-fit: cover` comment there). */
  object-fit: cover;
  object-position: center;
  background: #000;
  display: block;
}
/* MENUS (2026-08-04, docs/slice3/MENUS.md): when Firestorm's own UI is shown,
   `cover`'s symmetric center-crop hides most of the menu bar/toolbar width at
   a phone's portrait aspect (measured ~55-60% of frame width cropped at
   390px) -- the very thing this toggle exists to make clickable. Switch to
   `contain` (full frame always visible, letterboxed instead of cropped)
   whenever the toggle is on; app.js's click remap picks the matching inverse
   formula for whichever mode is active (see uiToggle handling there). This
   trades a smaller effective world view for every menu item actually being
   reachable, which is the right trade while menus are the point. */
body.ui-shown #stage video { object-fit: contain; }

/* ---------------------------------------------------------- tap-to-start */
#tapOverlay {
  position: absolute;
  inset: 0;
  z-index: var(--z-tapgate);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  background: rgba(10, 20, 32, 0.82);
  text-align: center;
  padding: 24px;
  cursor: pointer;
  transition: opacity var(--dur) var(--ease);
}
#tapOverlay .ripple { font-size: 56px; --ripple-color: var(--dock-lamp); }
#tapOverlay .tapLabel {
  font-family: var(--font-display);
  font-size: 19px;
  font-weight: 700;
}
#tapOverlay .tapHint {
  font-size: 13px;
  color: var(--foam-60);
  max-width: 280px;
  line-height: 1.4;
}
#tapOverlay.hidden { opacity: 0; pointer-events: none; }

/* --------------------------------------------------------- fullscreen fab */
#fullscreenToggle {
  position: absolute;
  right: calc(14px + var(--safe-r));
  bottom: calc(14px + var(--safe-b));
  z-index: var(--z-fab);
  width: 46px;
  height: 46px;
  border-radius: 50%;
  border: 1px solid var(--foam-15);
  background: rgba(18, 32, 47, 0.72);
  color: var(--foam);
  font-size: 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  backdrop-filter: blur(6px);
}
#fullscreenToggle:hover { background: rgba(18, 32, 47, 0.92); }

/* -------------------------------------------------- Firestorm-menus toggle */
#uiToggle {
  position: absolute;
  right: calc(14px + var(--safe-r));
  top: calc(14px + var(--safe-t));
  z-index: var(--z-fab);
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1px solid var(--foam-15);
  background: rgba(18, 32, 47, 0.72);
  color: var(--foam);
  font-size: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  backdrop-filter: blur(6px);
  transition: background var(--dur) var(--ease), color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
#uiToggle:hover { background: rgba(18, 32, 47, 0.92); }
#uiToggle:disabled { opacity: 0.4; cursor: default; }
/* "on" = Firestorm's menus are currently shown -- amber, matching the other
   active/armed states in this file (.fsCamRow button.active, the record-style
   dock lamp accent) rather than a bespoke color. */
#uiToggle[aria-pressed="true"] { color: var(--dock-lamp); border-color: rgba(232, 162, 59, 0.5); }
body.fs-active #uiToggle { display: none; }

/* ---------------------------------------------------- text-input conduit fab
   docs/slice3/TEXT-INPUT.md. Same fab family as #uiToggle directly above --
   stacked underneath it (same right offset, +48px down) rather than beside
   it, so both stay reachable with a thumb at phone width without crowding
   the top-right corner. */
#keyboardToggle {
  position: absolute;
  right: calc(14px + var(--safe-r));
  top: calc(14px + 48px + var(--safe-t));
  z-index: var(--z-fab);
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1px solid var(--foam-15);
  background: rgba(18, 32, 47, 0.72);
  color: var(--foam);
  font-size: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  backdrop-filter: blur(6px);
  transition: background var(--dur) var(--ease), color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
#keyboardToggle:hover { background: rgba(18, 32, 47, 0.92); }
/* "on" = text mode is active -- same amber-active language as #uiToggle
   above, so the two fabs that both mean "this is armed right now" read
   consistently instead of inventing a second visual vocabulary. This is
   also the at-a-glance mode indicator the task asked for: amber ⌨ + the
   conduit bar visible means keys type; anything else means keys walk. */
#keyboardToggle[aria-pressed="true"] { color: var(--dock-lamp); border-color: rgba(232, 162, 59, 0.5); }
body.fs-active #keyboardToggle { display: none; }

/* Pinned to the TOP of the viewport, not the bottom -- see deck.html's
   comment on the element itself: a phone's own virtual keyboard covers
   roughly the bottom third-to-half of the screen, and this bar exists
   specifically to stay visible above it. Higher z-index than #statusStrip
   (40) since both can technically be on-screen at once and the conduit,
   being the thing actively in use, should win. */
.textConduitBar {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  z-index: var(--z-conduit);
  display: flex;
  align-items: center;
  gap: 8px;
  padding: calc(10px + var(--safe-t)) calc(12px + var(--safe-r)) 10px calc(12px + var(--safe-l));
  background: rgba(10, 20, 32, 0.97);
  border-bottom: 1px solid rgba(232, 162, 59, 0.45);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.textConduitBar[hidden] { display: none; }
.textConduitBar .conduitModeLabel {
  flex: none;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.02em;
  color: var(--dock-lamp);
  white-space: nowrap;
}
.textConduitBar input {
  flex: 1;
  min-width: 0;
  background: var(--undertow);
  border: 1px solid var(--foam-15);
  border-radius: var(--radius-sm);
  color: var(--foam);
  /* 16px, not smaller: iOS Safari auto-zooms the whole page on focusing any
     input under 16px, which would fight the "stay visible, stay put" point
     of this whole bar. */
  font-size: 16px;
  padding: 9px 10px;
  font-family: var(--font-body);
}
.textConduitBar input:focus { outline: none; border-color: var(--moonwake); }
.textConduitBar button { flex: none; }
body.fs-active .textConduitBar { display: none; }

/* ------------------------------------------------- click-feedback crosshair
   Instant, purely local "here's where that lands" marker -- see the comment
   on #clickCrosshair in deck.html. Two hairlines + a small ring, not a solid
   dot, so it stays legible over both bright sky and dark interiors without
   fully hiding whatever's under the fingertip. */
#clickCrosshair {
  position: absolute;
  z-index: var(--z-touch);
  width: 28px;
  height: 28px;
  left: 0;
  top: 0;
  transform: translate(-50%, -50%) scale(0.6);
  opacity: 0;
  pointer-events: none;
  transition: opacity 160ms var(--ease), transform 160ms var(--ease);
}
#clickCrosshair::before,
#clickCrosshair::after {
  content: '';
  position: absolute;
  background: var(--moonwake);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);
}
#clickCrosshair::before { left: 50%; top: 3px; bottom: 3px; width: 1.5px; transform: translateX(-50%); }
#clickCrosshair::after { top: 50%; left: 3px; right: 3px; height: 1.5px; transform: translateY(-50%); }
#clickCrosshair.visible { opacity: 0.85; transform: translate(-50%, -50%) scale(1); }
#clickCrosshair.tapped { opacity: 0.95; transform: translate(-50%, -50%) scale(1.25); transition-duration: 60ms; }
/* right-click/long-press: same shape, dock-lamp accent instead of moonwake,
   so a glance tells left vs right apart without reading anything. */
#clickCrosshair.rightTap::before,
#clickCrosshair.rightTap::after { background: var(--dock-lamp); }
body.fs-active #clickCrosshair { display: none; }

/* ------------------------------------------------------------- tab sheet */
#tabSheet {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: var(--z-panel);
  max-height: 60%;
  background: rgba(18, 32, 47, 0.94);
  backdrop-filter: blur(10px);
  border-top: 1px solid var(--foam-15);
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
  transform: translateY(100%);
  transition: transform var(--dur) var(--ease);
  display: flex;
  flex-direction: column;
  box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.4);
}
#tabSheet.open { transform: translateY(0); }
#tabSheet .sheetHead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 16px 6px;
  flex: none;
}
#tabSheet .sheetHead h2 {
  font-family: var(--font-display);
  font-size: 16px;
  font-weight: 700;
  margin: 0;
}
#tabSheet .sheetClose {
  background: none;
  border: none;
  color: var(--foam-60);
  font-size: 20px;
  line-height: 1;
  padding: 6px;
  cursor: pointer;
}
#tabSheet .sheetBody {
  padding: 4px 16px 16px;
  overflow-y: auto;
  font-size: 13.5px;
  line-height: 1.5;
  /* LAYOUT FIX (docs/slice3/IM.md): #tabSheet is bottom:0 (flush with the
     viewport, same edge #tabBar's own ~61px sits on) but #tabBar has a
     HIGHER z-index (45 vs #tabSheet's 20) so it stays visible/tappable
     while a sheet is open -- correct for switching tabs, but it means the
     bottom ~61px of ANY sheet's content renders underneath #tabBar: present
     in the DOM, invisible, and unclickable (confirmed via headless-Chrome
     CDP's elementFromPoint() landing on a #tabBar icon instead of the
     element actually drawn there -- see IM.md's verification section).
     Latent before this feature because most tabs' actionable controls sit
     near the top; the IM composer's Send button -- deliberately anchored at
     the BOTTOM for one-handed reach -- was the first control to land
     squarely in the dead zone. Padding (not shrinking #tabSheet itself)
     keeps the existing blur/backdrop visually flush with the screen edge
     while pushing actual scrollable content clear of the occluded strip.
     76px matches .imToast's existing tab-bar clearance below, not a new
     number invented for this. */
  padding-bottom: calc(76px + var(--safe-b));
}
#tabSheet .gapNote {
  background: var(--dock-lamp-dim);
  border: 1px solid rgba(232, 162, 59, 0.35);
  border-radius: var(--radius-sm);
  padding: 10px 12px;
  color: var(--foam);
  font-size: 12.5px;
  margin: 8px 0 14px;
}
#tabSheet .stubNote {
  color: var(--foam-60);
  font-size: 13px;
  padding: 18px 4px 8px;
}

/* Do tab section labels/rows (docs/slice3/MOVEMENT.md overhaul) -- same
   visual language the rest of this file already expresses via one-off
   inline styles on <label> elements (uppercase, letter-spaced, Foam-60%),
   promoted to real classes here since the Do tab now has enough repeated
   sections (Movement/Camera/Gestures/Menus/Keyboard) that inlining each one
   would be pure duplication. */
.doSectionLabel {
  display: block;
  font-size: 11.5px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--foam-60);
  margin: 0 0 8px;
}
.doRow { display: flex; gap: 8px; flex-wrap: wrap; }
.doRow .ctlChip[data-fscam] { flex: 1; min-width: 72px; text-align: center; }

/* keyboard-shortcut legend, Do tab (docs/slice3/MENUS.md) -- discoverable
   > undocumented: a shortcut nobody can find may as well not exist. */
.keyLegend { display: flex; flex-direction: column; gap: 8px; font-size: 12.5px; color: var(--foam-60); }
.keyLegend .keyCap {
  display: inline-block;
  min-width: 20px;
  text-align: center;
  padding: 2px 6px;
  margin-right: 5px;
  background: var(--undertow-2);
  border: 1px solid var(--foam-15);
  border-radius: 5px;
  font-family: var(--font-mono, monospace);
  font-size: 11px;
  color: var(--foam);
}

/* chat draft field, only meaningful inside the Chat sheet */
.draftRow { display: flex; gap: 8px; margin-bottom: 4px; }
.draftRow input {
  flex: 1;
  background: var(--undertow-2);
  border: 1px solid var(--foam-15);
  border-radius: var(--radius-sm);
  padding: 10px 12px;
  font-size: 14px;
  color: var(--foam);
}
.draftRow input::placeholder { color: var(--foam-35); }
.draftHint { font-size: 11.5px; color: var(--foam-35); margin: 0 0 12px; }

/* Chat thread -- a transcript, not a log: no per-row chrome (no bubble
   background, no boxes) so it reads at a glance one-handed over moving
   video. Three things a reader needs told apart, done with type alone:
     - a person talking: full-weight name + message (chatRow--agent)
     - an object chattering: receded -- smaller, quieter (chatRow--object,
       "objects chattering are usually noise" -- docs/slice3/DESIGN.md)
     - ambient (teleports/notices/who's-nearby, and the viewer's own
       CHAT_SOURCE_SYSTEM lines): a centred, muted aside, not a chat row
   Whisper/shout are a size+weight shift, not a "(whispers)" label. */
.chatThread {
  display: flex;
  flex-direction: column;
  gap: 9px;
  max-height: 280px;
  overflow-y: auto;
  margin-top: 4px;
  padding-bottom: 4px;
}
.chatRow { line-height: 1.4; font-size: 13.5px; }
.chatRow .chatFrom { font-weight: 600; color: var(--foam); margin-right: 6px; }
.chatRow .chatMsg { color: var(--foam); }
.chatRow--object .chatFrom,
.chatRow--object .chatMsg { color: var(--foam-60); font-size: 12px; }
.chatRow--object .chatFrom { font-weight: 500; }
.chatRow.chatType-whisper .chatMsg { font-size: 12px; font-style: italic; color: var(--foam-60); }
.chatRow.chatType-shout .chatMsg { font-size: 15.5px; font-weight: 600; letter-spacing: 0.005em; }
.systemLine {
  text-align: center;
  font-size: 11px;
  color: var(--foam-35);
  margin: 2px 0;
}

/* ------------------------------------------------------ direct messages
   (docs/slice3/IM.md). Lives inside the Chat tab, switched via a small
   segmented control rather than a new tab bar icon -- private 1:1 IMs and
   nearby chat are both "things people say to me", just scoped differently,
   and the tab bar is already a closed set of six. */
.chatSegment {
  display: flex;
  gap: 4px;
  padding: 3px;
  margin-bottom: 10px;
  background: var(--undertow-2);
  border-radius: 999px;
}
.chatSegment .segBtn {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0;
  background: none;
  border: none;
  color: var(--foam-60);
  border-radius: 999px;
  padding: 8px 10px;
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
}
.chatSegment .segBtn[aria-pressed="true"] { background: var(--undertow); color: var(--foam); }

/* Thread inbox list -- reuses .peopleList's row rhythm (same tap-target
   size as People/Go/Inventory rows) rather than inventing a new list style. */
.imThreadRow {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 11px 10px;
  background: var(--undertow-2);
  border-radius: var(--radius-sm);
  font-size: 13.5px;
  cursor: pointer;
  text-align: left;
  width: 100%;
  border: none;
  color: var(--foam);
}
.imThreadRow .imThreadMain { flex: 1; min-width: 0; }
.imThreadRow .imThreadFrom { font-weight: 600; }
.imThreadRow.unread .imThreadFrom { font-weight: 700; }
.imThreadRow .imThreadPreview {
  display: block;
  color: var(--foam-60);
  font-size: 12px;
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.imThreadRow.unread .imThreadPreview { color: var(--foam); }

/* Single-conversation view */
.imThreadHead {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}
.imThreadHead .imThreadName {
  flex: 1;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 14.5px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.imMessages {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-height: 280px;
  overflow-y: auto;
  padding: 4px 2px 8px;
}
/* Deliberately not chat-app bubble backgrounds everywhere -- alignment +
   color alone (matching this file's "no per-row chrome" chat-thread rule)
   keeps it legible at this font size over moving video. */
.imMsg { max-width: 84%; line-height: 1.4; font-size: 13.5px; }
.imMsg--in { align-self: flex-start; color: var(--foam); }
.imMsg--out { align-self: flex-end; text-align: right; color: var(--moonwake); }
.imMsg .imMsgText {
  display: inline-block;
  padding: 8px 11px;
  border-radius: var(--radius-sm);
  background: var(--undertow-2);
}
.imMsg--out .imMsgText { background: var(--moonwake-dim); }
.imMsg .imMsgMeta { display: block; font-size: 10px; color: var(--foam-35); margin-top: 3px; }
.imMsg--pending .imMsgText { opacity: 0.55; }
.imMsg--failed .imMsgText { background: var(--flare-dim); border: 1px solid rgba(255, 110, 90, 0.4); }
.imMsg--failed .imMsgMeta { color: var(--flare); cursor: pointer; }

/* Composer -- large tap target, thumb-reachable send button, deliberately
   NOT auto-sending on any input event (see .draftRow's chatdraft, which
   does live-update the in-world box but that's a different, non-committing
   action). Enter and the Send button are the only two ways a message ever
   actually goes out. Shared by the IM composer (textarea, multi-line) and
   the nearby-chat composer (plain text input, single-line -- SL nearby
   chat has no multi-line concept) -- same sizing/tap-target rules either
   way, so one selector covers both rather than duplicating the block. */
.imComposer { display: flex; gap: 8px; align-items: flex-end; margin-top: 6px; }
.imComposer textarea,
.imComposer input[type="text"] {
  flex: 1;
  resize: none;
  background: var(--undertow-2);
  border: 1px solid var(--foam-15);
  border-radius: var(--radius-sm);
  padding: 12px 13px;
  font-size: 15px;
  font-family: inherit;
  color: var(--foam);
  min-height: 46px;
  max-height: 110px;
}
.imComposer textarea::placeholder,
.imComposer input[type="text"]::placeholder { color: var(--foam-35); }
.imComposer button {
  flex: none;
  min-width: 58px;
  min-height: 46px;
  background: var(--dock-lamp);
  color: var(--deep-water);
  border: none;
  border-radius: var(--radius-sm);
  font-weight: 700;
  font-size: 13.5px;
  cursor: pointer;
}
.imComposer button:disabled { opacity: 0.45; cursor: default; }
.imSendError { color: var(--flare); font-size: 12px; margin-top: 6px; }

.peopleList { display: flex; flex-direction: column; gap: 6px; }
.peopleList .person {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 10px;
  background: var(--undertow-2);
  border-radius: var(--radius-sm);
  font-size: 13.5px;
}
.peopleList .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--moonwake); flex: none; }

.ctlChip {
  background: var(--undertow);
  border: 1px solid var(--foam-15);
  color: var(--foam);
  border-radius: 999px;
  padding: 7px 12px;
  font-size: 12px;
  cursor: pointer;
  flex: none;
  white-space: nowrap;
}
.ctlChip:hover { border-color: var(--moonwake); }
.ctlChip:active { background: var(--undertow-2); }
.ctlChip:disabled { opacity: 0.5; cursor: default; }
.ctlChip[aria-pressed="true"] { color: var(--dock-lamp); border-color: rgba(232, 162, 59, 0.5); }

/* hold-to-confirm -- the one blast-radius control in this slice (teleport).
   Flare is spent here on purpose (docs/slice3/DESIGN.md keeps Flare scarce
   so it still reads as "this one matters" when it shows up). */
.holdConfirm {
  position: relative;
  width: 100%;
  margin-top: 14px;
  padding: 16px;
  border-radius: var(--radius-md);
  border: 1px solid rgba(255, 110, 90, 0.4);
  background: var(--undertow-2);
  color: var(--foam);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 14.5px;
  cursor: pointer;
  overflow: hidden;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}
.holdConfirm .fill {
  position: absolute;
  inset: 0;
  width: 0%;
  background: var(--flare-dim);
}
.holdConfirm.holding .fill { width: 100%; transition: width 0.85s linear; }
.holdConfirm.done .fill { width: 100%; background: var(--flare); transition: background 0.2s ease; }
.holdConfirm .lbl { position: relative; z-index: 1; }
@media (prefers-reduced-motion: reduce) {
  .holdConfirm.holding .fill, .holdConfirm.done .fill { transition: none; }
}

/* ---------------------------------------------------------------- tab bar */
#tabBar {
  flex: none;
  z-index: var(--z-tabbar);
  display: flex;
  justify-content: center;
  gap: 2px;
  padding: 6px calc(6px + var(--safe-l)) calc(6px + var(--safe-b)) calc(6px + var(--safe-r));
  background: var(--undertow);
  border-top: 1px solid var(--foam-15);
}
.tabBtn {
  flex: 1 1 0;
  max-width: 96px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: 7px 2px 6px;
  background: none;
  border: none;
  border-radius: var(--radius-sm);
  color: var(--foam-60);
  cursor: pointer;
}
.tabBtn .icon { font-size: 19px; line-height: 1; position: relative; }
.tabBtn .label { font-size: 10.5px; font-weight: 500; letter-spacing: 0.01em; }

/* Unread badge (docs/slice3/IM.md) -- reused on the Chat tab icon and on the
   Messages segment inside it. Flare, not Dock-lamp: this is the one other
   place in the file an "attention" color earns its scarcity (see
   docs/slice3/DESIGN.md's "spend Flare on purpose" rule) -- an unread
   private message is a different kind of urgent than "menus are toggled on". */
.badge {
  position: absolute;
  top: -4px;
  right: -9px;
  min-width: 15px;
  height: 15px;
  padding: 0 4px;
  border-radius: 999px;
  background: var(--flare);
  color: var(--deep-water);
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  line-height: 15px;
  text-align: center;
  box-shadow: 0 0 0 2px var(--undertow);
}
.badge[hidden] { display: none; }
/* Inline badge (Messages segment button, thread-list rows) sits in normal
   flow next to text instead of pinned to a corner. */
.badge.badge--inline { position: static; margin-left: 6px; box-shadow: none; }

/* New-IM arrival toast -- bottom-anchored so it never covers the video's
   click-through area or the status strip up top, auto-fades, and is a real
   button (tappable, opens the thread) rather than a passive banner. */
.imToast {
  position: fixed;
  left: 50%;
  bottom: calc(76px + var(--safe-b));
  z-index: var(--z-toast);
  transform: translate(-50%, 12px);
  max-width: min(88vw, 360px);
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 11px 14px;
  background: rgba(18, 32, 47, 0.96);
  border: 1px solid rgba(255, 110, 90, 0.4);
  border-radius: var(--radius-md);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
  color: var(--foam);
  font-size: 13px;
  text-align: left;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.imToast[hidden] { display: none; }
.imToast.shown { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
.imToast .imToastFrom { font-weight: 700; }
.imToast .imToastMsg {
  display: block;
  color: var(--foam-60);
  font-size: 12px;
  margin-top: 1px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
body.fs-active .imToast { display: none; }
@media (prefers-reduced-motion: reduce) {
  .imToast { transition: none; }
}
.tabBtn[aria-pressed="true"] {
  color: var(--dock-lamp);
}
.tabBtn[aria-pressed="true"] .label {
  font-family: var(--font-display);
  font-weight: 700;
}
.tabBtn:active { background: rgba(233, 241, 243, 0.06); }

/* ---------------------------------------------------------- fullscreen 3D
   Edge-to-edge video, deck chrome (status strip, tab bar, open sheet) goes
   away entirely, replaced by the translucent overlay controls below. */
body.fs-active #tabBar,
body.fs-active #statusStrip,
body.fs-active #tabSheet,
body.fs-active #fullscreenToggle,
body.sheet-open #fullscreenToggle { display: none; }
body.fs-active #app { display: block; }
body.fs-active #stage {
  position: fixed;
  inset: 0;
  z-index: var(--z-stage-fs);
}

#fsLayer {
  position: absolute;
  inset: 0;
  z-index: var(--z-fslayer);
  display: none;
  touch-action: none;
}
#stage.fullscreen3d #fsLayer { display: block; }

#fsExit {
  position: absolute;
  right: calc(14px + var(--safe-r));
  top: calc(14px + var(--safe-t));
  z-index: var(--z-fschrome);
  width: 42px;
  height: 42px;
  border-radius: 50%;
  border: 1px solid var(--foam-15);
  background: rgba(10, 20, 32, 0.6);
  color: var(--foam);
  font-size: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  backdrop-filter: blur(6px);
  cursor: pointer;
}

#fsHint {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(14px + var(--safe-t));
  text-align: center;
  font-size: 11.5px;
  color: var(--foam-60);
  pointer-events: none;
  transition: opacity 0.6s var(--ease) 1.4s;
}
#fsHint.faded { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  #fsHint { transition: none; }
}

.fsCamRow {
  position: absolute;
  left: 0;
  right: 0;
  /* OVERLAP FIX (docs/slice3/DESIGN.md "Layering & spacing system"): was a
     flat top:40px chosen independently of the row-1 chips above it (up to
     42px tall) -- close enough to look fine at first glance, wrong enough
     that a later addition below it (.fsUiToggle) inherited the mistake.
     Derived from --fs-row1-bottom instead. */
  top: var(--fs-row2-top);
  z-index: var(--z-fschrome);
  display: flex;
  justify-content: center;
  gap: 8px;
}
.fsCamRow button {
  background: rgba(10, 20, 32, 0.6);
  border: 1px solid var(--foam-15);
  color: var(--foam-60);
  border-radius: 999px;
  padding: 6px 14px;
  font-size: 12px;
  backdrop-filter: blur(6px);
  cursor: pointer;
}
.fsCamRow button:active,
.fsCamRow button.active { color: var(--dock-lamp); border-color: rgba(232, 162, 59, 0.5); }

/* INPUT RELIABILITY (docs/slice3/INPUT-RELIABILITY.md): fullscreen's own
   connection readout -- same states/colors as #statusStrip (data-state
   drives the ripple color the same way), just sized for the fullscreen
   mockup's "tiny translucent readout, top-center-ish" slot instead of the
   full-width strip. See deck.html's comment on #fsStatus for why this only
   ever appears gated behind body.fs-active, and app.js's applyFsStatus()
   for when it's actually shown vs kept hidden. */
#fsStatus {
  position: absolute;
  left: 50%;
  top: calc(14px + var(--safe-t));
  transform: translateX(-50%);
  z-index: var(--z-fschrome);
  display: none;
  align-items: center;
  gap: 0.5em;
  padding: 5px 12px;
  border-radius: 999px;
  background: rgba(10, 20, 32, 0.72);
  border: 1px solid var(--foam-15);
  backdrop-filter: blur(6px);
  font-size: 11.5px;
  color: var(--foam);
  white-space: nowrap;
  pointer-events: none;
}
body.fs-active #fsStatus:not([hidden]) { display: flex; }
#fsStatus .ripple { font-size: 8px; flex: none; }
#fsStatus[data-state="reconnecting"] .ripple,
#fsStatus[data-state="error"] .ripple { --ripple-color: var(--flare); }

/* MOVEMENT (docs/slice3/MOVEMENT.md), owner ask: "show the state" (walking
   vs flying) + a deliberate Fly toggle. Balances #fsExit on the opposite
   corner; disabled/greyed until the real state is known (see app.js's
   refreshFlying(), same "never assume" rule #uiToggle already follows). */
.fsFlyToggle {
  position: absolute;
  left: calc(14px + var(--safe-l));
  top: calc(14px + var(--safe-t));
  z-index: var(--z-fschrome);
  background: rgba(10, 20, 32, 0.6);
  border: 1px solid var(--foam-15);
  color: var(--foam-60);
  border-radius: 999px;
  padding: 8px 14px;
  font-size: 12px;
  backdrop-filter: blur(6px);
  cursor: pointer;
}
.fsFlyToggle[aria-pressed="true"] { color: var(--moonwake); border-color: rgba(127, 216, 204, 0.5); }
.fsFlyToggle:disabled { opacity: 0.5; cursor: default; }

/* LEFT-DRAG ARBITRATION discoverability fix (2026-08-04, docs/slice3/
   MOBILE-CONTROLS.md "Part 2" postmortem, see deck.html's comment on
   #fsUiToggle for the full why). Stacked directly under .fsFlyToggle at the
   same left anchor -- same chip language, own row, so it never competes
   with .fsCamRow's centered buttons above it. Text is intentionally wider
   than the other fullscreen chips (it's explaining a mode, not just naming
   a state) -- font-size drops slightly and white-space is allowed to wrap
   on narrow viewports rather than truncate silently. */
.fsUiToggle {
  /* OVERLAP FIX -- this is the confirmed live bug (docs/slice3/DESIGN.md):
     58px put this chip's top 11px INSIDE .fsCamRow's bottom edge (40+29px
     tall = 69px), which a captured frame showed as the "Menus hidden --
     drag orbits camera" pill's top border slicing straight through the
     "Default" camera-preset button, at every viewport width tested (the
     two rows are both anchored to fixed pixel offsets, not to each other,
     so viewport width was never actually the variable that mattered here).
     --fs-row3-top is .fsCamRow's own real bottom + a shared gap token, so
     this can't independently drift out of sync with it again. */
  top: var(--fs-row3-top);
  left: calc(14px + var(--safe-l));
  max-width: min(220px, calc(100vw - 28px - var(--safe-l) - var(--safe-r)));
  border-radius: 14px; /* a wrapped two-line chip reads as a card, not a pill -- 999px here would just round toward a stadium shape at this height */
  font-size: 11px;
  line-height: 1.3;
  text-align: left;
  white-space: normal;
}
.fsUiToggle[aria-pressed="true"] { color: var(--dock-lamp); border-color: rgba(232, 162, 59, 0.5); }

/* INPUT RELIABILITY: the exact moment a keypress/joystick drag/tap fails to
   reach the viewer (control socket not open) -- rate-limited (see app.js's
   reportControlFailure()) so a held key during an outage doesn't spam this
   on every resend. Deliberately NOT hidden in fullscreen (unlike #imToast)
   -- movement is exactly what's silently failing, so this has to be visible
   in the one mode where it's most needed. Top-anchored, below the fullscreen
   status readout / above the main strip, so it never competes with either. */
.connToast {
  position: fixed;
  left: 50%;
  top: calc(56px + var(--safe-t));
  z-index: var(--z-toast-urgent);
  transform: translate(-50%, -8px);
  max-width: min(88vw, 340px);
  padding: 9px 14px;
  background: rgba(18, 32, 47, 0.96);
  border: 1px solid rgba(255, 110, 90, 0.4);
  border-radius: var(--radius-md);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
  color: var(--foam);
  font-size: 12.5px;
  text-align: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.connToast[hidden] { display: none; }
.connToast.shown { opacity: 1; transform: translate(-50%, 0); }
@media (prefers-reduced-motion: reduce) {
  .connToast { transition: none; }
}

/* PRIVACY (2026-08-05, docs/slice3/AUDIO.md "cross-tenant audio bleed"):
   bottom-anchored (mirrors #connToast's top anchor) so it never overlaps the
   connection-status readout, and persistent rather than a fading toast --
   this is a standing fact about the session ("audio is off"), not a
   one-time event, so it should stay visible the whole time it's true,
   including in fullscreen (see deck.html's comment for why it isn't in
   fs-active's hide list). Neutral/informational styling (foam, not the
   urgent coral connToast uses) since this is expected, not an error. */
.audioNotice {
  position: fixed;
  left: 50%;
  bottom: calc(18px + var(--safe-b, 0px));
  z-index: var(--z-toast-urgent);
  transform: translateX(-50%);
  max-width: min(88vw, 380px);
  padding: 9px 14px;
  background: rgba(18, 32, 47, 0.96);
  border: 1px solid var(--foam-15);
  border-radius: var(--radius-md);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
  color: var(--foam-60);
  font-size: 12.5px;
  text-align: center;
  pointer-events: none;
}
.audioNotice[hidden] { display: none; }

#joystick {
  position: absolute;
  left: calc(20px + var(--safe-l));
  bottom: calc(20px + var(--safe-b));
  width: 108px;
  height: 108px;
  border-radius: 50%;
  background: rgba(18, 32, 47, 0.55);
  border: 1px solid var(--foam-15);
  backdrop-filter: blur(4px);
}
#joystick .nub {
  position: absolute;
  width: 46px;
  height: 46px;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: rgba(232, 162, 59, 0.85);
  transition: transform 0.08s linear;
}
#joystick .nub::after {
  content: '';
  position: absolute;
  inset: -10px;
  border-radius: 50%;
  border: 1px solid rgba(232, 162, 59, 0.4);
  animation: ripple-expand 2.4s var(--ease) infinite;
}
@media (prefers-reduced-motion: reduce) {
  #joystick .nub::after { animation: none; opacity: 0.5; }
}
#joystick .label {
  position: absolute;
  bottom: -22px;
  left: 0;
  right: 0;
  text-align: center;
  font-size: 10.5px;
  color: var(--foam-60);
}

/* ----------------------------------- iOS-style dual touch controls -------
   docs/slice3/MOBILE-CONTROLS.md. Touch-primary devices only (body.
   touch-device, set once at boot by app.js's isTouchPrimary()) -- a mouse/
   keyboard session never sees this pair and keeps the single #joystick +
   whole-area drag-to-look above, completely unchanged. Two independent
   hit-zones, left = move, right = camera, each owning its own pointerId so
   a thumb on each can drive both at once (see app.js). Both zones stop
   below the top exclusion band (#fsExit/#fsFlyToggle/.fsCamRow/#fsStatus)
   so those controls keep first claim on their own screen real estate
   without needing z-index games or e.target.closest() exclusions -- the
   zones simply don't extend under them. */
body.touch-device #joystick { display: none; }
#touchMoveZone, #touchLookZone {
  position: absolute;
  /* Was a flat 84px guess -- didn't clear .fsUiToggle's real bottom edge
     (up to 106px + 46px = 152px at its two-line worst case), so a thumb
     landing in that band could land on the invisible top of a touch zone
     instead of the chip on top of it. Derived from the same fullscreen
     row-stack tokens .fsUiToggle itself uses, so the two can't drift apart
     again. */
  top: calc(var(--fs-row3-max-bottom) + var(--fs-row-gap) + var(--safe-t));
  bottom: var(--safe-b);
  display: none;
  touch-action: none;
  z-index: var(--z-touch); /* above the video/click-through layer, below the fullscreen chrome tier (--z-fschrome) */
}
body.touch-device #touchMoveZone,
body.touch-device #touchLookZone { display: block; }
#touchMoveZone { left: var(--safe-l); width: 46%; }
#touchLookZone { right: var(--safe-r); width: 54%; }

/* The floating-origin move stick: normally hidden (opacity 0, no hit-
   testing of its own -- the ZONE owns pointer capture, not this visual),
   and repositioned via inline left/top to MATERIALIZE centered on wherever
   the thumb actually lands, per-touch -- never a fixed dock point. This is
   deliberately the single biggest difference from the old fixed #joystick
   (see MOBILE-CONTROLS.md's "why floating origin" section). */
.touchStick {
  position: absolute;
  width: 108px;
  height: 108px;
  margin-left: -54px;
  margin-top: -54px;
  border-radius: 50%;
  background: rgba(18, 32, 47, 0.55);
  border: 1px solid var(--foam-15);
  backdrop-filter: blur(4px);
  opacity: 0;
  transform: scale(0.55);
  pointer-events: none;
  transition: opacity 0.14s var(--ease), transform 0.14s var(--ease);
}
.touchStick.armed { opacity: 1; transform: scale(1); }
/* Release: nub springs back to center over a slightly longer, bouncier
   curve than the arm-in (a "spring", not a snap); the ring itself lingers
   briefly then fades, rather than vanishing instantly, so the release
   reads as deliberate rather than a glitch. */
.touchStick.releasing { transition: opacity 0.32s var(--ease) 0.12s, transform 0.32s var(--ease); }
.touchStickNub {
  position: absolute;
  width: 46px;
  height: 46px;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: rgba(232, 162, 59, 0.85);
  transition: transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.touchStickNub::after {
  content: '';
  position: absolute;
  inset: -10px;
  border-radius: 50%;
  border: 1px solid rgba(232, 162, 59, 0.4);
  animation: ripple-expand 2.4s var(--ease) infinite;
}
@media (prefers-reduced-motion: reduce) {
  .touchStick, .touchStick.armed, .touchStick.releasing { transition: opacity 0.1s linear; transform: none; }
  .touchStickNub { transition: none; }
  .touchStickNub::after { animation: none; opacity: 0.5; }
}

/* The look-pad has no persistent stick (see MOBILE-CONTROLS.md "drag-pad,
   not a second stick" for why) -- just a container the one-shot
   materialize ping (a real .ripple, reused, not reinvented) gets inserted
   into at the touch point and removed again shortly after. Deliberately no
   background/border of its own so the pad reads as "the whole right side
   is live", the PUBG-Mobile/Fortnite-Mobile convention this borrows. */
#touchLookRing {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
#touchLookRing .ripple {
  --ripple-color: var(--moonwake);
  position: absolute;
  width: 64px;
  height: 64px;
  margin-left: -32px;
  margin-top: -32px;
  opacity: 0;
  animation: touchLookPing 0.5s var(--ease) forwards;
}
@keyframes touchLookPing {
  0% { opacity: 0.85; }
  100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  #touchLookRing .ripple { animation: none; opacity: 0; }
}

/* --------------------------------------------------------------- sign in */
.signinWrap {
  min-height: 100%;
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px calc(20px + var(--safe-l)) calc(24px + var(--safe-b)) calc(20px + var(--safe-r));
}
.signinCard {
  width: 100%;
  max-width: 340px;
}
.signinCard .brand {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 6px;
}
.signinCard .brand .ripple { font-size: 22px; --ripple-color: var(--dock-lamp); }
.signinCard h1 {
  font-family: var(--font-display);
  font-size: 26px;
  font-weight: 700;
  margin: 0;
}
.signinCard .sub {
  color: var(--foam-60);
  font-size: 13.5px;
  margin: 0 0 26px;
  line-height: 1.5;
}
.signinCard label {
  display: block;
  font-size: 11.5px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--foam-60);
  margin: 16px 0 6px;
}
.signinCard input {
  width: 100%;
  background: var(--undertow-2);
  border: 1px solid var(--foam-15);
  border-radius: var(--radius-sm);
  padding: 13px 14px;
  font-size: 16px;
  color: var(--foam);
}
.signinCard input:focus { border-color: var(--moonwake); }
.signinCard button[type="submit"] {
  width: 100%;
  margin-top: 22px;
  background: var(--dock-lamp);
  color: var(--deep-water);
  border: none;
  border-radius: var(--radius-sm);
  padding: 14px;
  font-size: 15px;
  font-weight: 700;
  font-family: var(--font-display);
  cursor: pointer;
  transition: filter var(--dur) var(--ease);
}
.signinCard button[type="submit"]:hover:not(:disabled) { filter: brightness(1.08); }
.signinCard button[type="submit"]:disabled { opacity: 0.55; cursor: default; }
.signinCard #error {
  display: none;
  margin-top: 16px;
  background: var(--flare-dim);
  border: 1px solid rgba(255, 110, 90, 0.4);
  border-radius: var(--radius-sm);
  padding: 11px 13px;
  color: var(--foam);
  font-size: 13px;
  line-height: 1.4;
}

/* ------------------------------------------------------------ breakpoint
   #app deliberately stays a column flex container (video grows, tab bar
   sits at its natural height at the bottom) at EVERY width -- an earlier
   draft switched #app to flex-direction:row + wrap here to seat the tab
   sheet beside the video instead of over it, but that silently broke the
   vertical flex-grow that pins the tab bar to the bottom (a wrapped flex
   line's cross-size is shared out by align-content, not driven by its
   content), so the tab bar ended up stranded at the vertical midpoint with
   dead space below it -- caught in a real captured screenshot, not just
   read off the CSS. Fixed by keeping the outer layout untouched at every
   width and instead turning the tab sheet into an absolutely-positioned
   panel that slides in from the right instead of up from the bottom --
   still an overlay, never a layout participant, so it can't perturb the
   column flow again. */
@media (min-width: 900px) {
  .tabBtn { max-width: 120px; flex-direction: row; gap: 8px; padding: 10px 16px; }
  #tabSheet {
    left: auto;
    right: 0;
    top: 0;
    bottom: 0;
    max-height: none;
    width: 360px;
    transform: translateX(100%);
    border-radius: 0;
    border-top: none;
    border-left: 1px solid var(--foam-15);
  }
  #tabSheet.open { transform: translateX(0); }
}

/* --------------------------------------------------- viewer settings (Me)
   docs/slice3/SETTINGS.md. One .settingRow per control; deliberately plain
   (no card chrome) since the Me tab already reads as a stack of labeled
   sections, not a grid of tiles. */
.settingRow { margin-bottom: 20px; }
.settingLabelRow { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.settingLabel { font-size: 14px; font-weight: 600; color: var(--foam); }
.settingValue { font-family: var(--font-mono); font-size: 12.5px; color: var(--foam-60); }
.settingHint { font-size: 12px; color: var(--foam-35); margin-top: 5px; line-height: 1.4; }

/* on/off switch -- same dock-lamp "armed" accent the UI-menu toggle and
   camera-preset buttons already use for "this is currently active". */
.settingSwitch { position: relative; display: inline-block; width: 42px; height: 24px; flex: none; }
.settingSwitch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
.settingSwitchTrack {
  position: absolute; inset: 0; border-radius: 999px;
  background: var(--undertow-2); border: 1px solid var(--foam-15);
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.settingSwitchThumb {
  position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%;
  background: var(--foam-60); transition: transform var(--dur) var(--ease), background var(--dur) var(--ease);
}
.settingSwitch input:checked ~ .settingSwitchTrack { border-color: rgba(232, 162, 59, 0.5); background: var(--dock-lamp-dim); }
.settingSwitch input:checked ~ .settingSwitchTrack .settingSwitchThumb { transform: translateX(18px); background: var(--dock-lamp); }
.settingSwitch input:focus-visible ~ .settingSwitchTrack { outline: 2px solid var(--moonwake); outline-offset: 2px; }

.settingSlider {
  width: 100%; margin-top: 10px; accent-color: var(--moonwake);
  background: transparent; cursor: pointer;
}
.settingSelect {
  width: 100%; margin-top: 8px; padding: 9px 10px; border-radius: var(--radius-sm);
  border: 1px solid var(--foam-15); background: var(--undertow-2); color: var(--foam);
  font-family: var(--font-body); font-size: 13.5px; cursor: pointer;
}

/* restart-required callout -- flare accent, this project's one "has real
   consequences" color (also used for the teleport hold-to-confirm), since
   a viewer restart is exactly that: a short but real disconnect. */
.settingBanner {
  margin-top: 10px; padding: 10px 12px; border-radius: var(--radius-sm);
  background: var(--flare-dim); border: 1px solid rgba(255, 110, 90, 0.4);
  font-size: 12.5px; color: var(--foam); line-height: 1.45;
}

/* Slice-4 SL credential form (docs/slice4/CREDENTIALS.md) -- text inputs
   reuse .imComposer's block/underwater look (background, border, radius)
   but full-width and stacked rather than a flex row, since this is a
   two-field form, not a one-line composer. */
.credInput {
  display: block; width: 100%; box-sizing: border-box;
  background: var(--undertow-2); border: 1px solid var(--foam-15);
  border-radius: var(--radius-sm); padding: 11px 12px;
  font-size: 14.5px; font-family: inherit; color: var(--foam);
}
.credInput::placeholder { color: var(--foam-35); }
.credInput:disabled { opacity: 0.55; }
.credInput:focus-visible { outline: 2px solid var(--moonwake); outline-offset: 1px; }

/* Three states of the one status line under the form: mid-flight
   (neutral/moonwake -- this can take up to ~90s, must not read as stuck
   or as an error), success (dock-lamp, this project's "armed/positive"
   accent), failure (flare -- same accent .settingBanner's restart-required
   callout uses, this project's one "has real consequences" color). */
.credBanner {
  margin-top: 10px; padding: 10px 12px; border-radius: var(--radius-sm);
  font-size: 12.5px; line-height: 1.45; color: var(--foam);
  border: 1px solid transparent;
}
.credBanner.progress { background: var(--moonwake-dim); border-color: rgba(127, 216, 204, 0.4); }
.credBanner.ok { background: var(--dock-lamp-dim); border-color: rgba(232, 162, 59, 0.5); }
.credBanner.error { background: var(--flare-dim); border-color: rgba(255, 110, 90, 0.4); }

/* ================================================================
   Slice 4 -- Admin surface (gateway/static/admin.html, admin_login.html).
   Reuses every token above (palette/fonts/radii/motion) rather than
   inventing a second design language for the operator-only pages -- this
   is the same closed 6-hex palette from docs/slice3/DESIGN.md §1, just
   arranged into a dashboard instead of a single-purpose deck. The
   admin_login.html page reuses .signinWrap/.signinCard directly (no new
   classes needed there); everything below is for admin.html.
   ================================================================ */

.adminSub { max-width: 340px; }

#adminApp {
  min-height: 100%;
  display: flex; flex-direction: column;
  padding: calc(var(--safe-t) + 18px) max(18px, var(--safe-r)) 40px max(18px, var(--safe-l));
  gap: 22px;
  max-width: 1080px;
  margin: 0 auto;
}

.adminHeader {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
}
.adminBrand { display: flex; align-items: center; gap: 10px; }
.adminBrand .ripple { font-size: 16px; --ripple-color: var(--dock-lamp); }
.adminBrand h1 { font-size: 20px; margin: 0; }
.adminTag {
  font-family: var(--font-mono); font-size: 11px; font-weight: 500;
  color: var(--moonwake); background: var(--moonwake-dim);
  border: 1px solid rgba(127, 216, 204, 0.4); border-radius: 999px;
  padding: 2px 9px; margin-left: 4px; vertical-align: middle;
  text-transform: uppercase; letter-spacing: 0.06em;
}
.adminWho { display: flex; align-items: center; gap: 12px; }
.adminWho .mono { font-size: 12.5px; color: var(--foam-60); }

.ghostBtn {
  background: transparent; border: 1px solid var(--foam-15); color: var(--foam);
  border-radius: var(--radius-sm); padding: 7px 14px; font-size: 13px; cursor: pointer;
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.ghostBtn:hover { background: var(--undertow-2); border-color: var(--foam-35); }

.adminMain { display: flex; flex-direction: column; gap: 20px; }

.adminCard {
  background: var(--undertow); border: 1px solid var(--foam-15);
  border-radius: var(--radius-lg); padding: 18px 20px;
}
.adminCard h2 { font-size: 15px; margin: 0 0 12px; font-family: var(--font-display); font-weight: 700; }
.adminCardHead { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.adminCardHead h2 { margin: 0; }

.capacityGrid {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 14px; margin-bottom: 10px;
}
.capacityStat {
  display: flex; flex-direction: column; gap: 2px;
  background: var(--undertow-2); border: 1px solid var(--foam-15);
  border-radius: var(--radius-md); padding: 12px 14px;
}
.capNum { font-family: var(--font-mono); font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
.capLabel { font-size: 11.5px; color: var(--foam-60); }
.capNote { font-size: 12px; color: var(--foam-35); line-height: 1.5; margin: 0; }

.tableWrap { overflow-x: auto; }
.tenantTable { width: 100%; border-collapse: collapse; font-size: 13px; }
.tenantTable th {
  text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--foam-35); padding: 6px 10px; border-bottom: 1px solid var(--foam-15);
  white-space: nowrap;
}
.tenantTable td { padding: 10px; border-bottom: 1px solid var(--foam-15); vertical-align: middle; white-space: nowrap; }
.tenantTable .dim { color: var(--foam-35); font-size: 11.5px; }
.emptyRow { color: var(--foam-35); text-align: center; padding: 20px !important; }

/* statusBadge, NOT .badge: this file already has a .badge (the unread-IM
   corner-pin dot, position:absolute -- see its own definition above) for a
   completely different purpose. Reusing that name here silently pinned
   these health badges off in a corner at ~15px height, effectively
   invisible in the tenant table -- caught by actually reading a
   screenshot, not by DOM inspection (the markup was correct all along).
   Renamed instead of overriding, so the two never fight over one name
   again. */
.statusBadge {
  display: inline-block; font-size: 11px; font-weight: 600; border-radius: 999px;
  padding: 2px 9px; text-transform: uppercase; letter-spacing: 0.03em;
}
.statusBadgeOk { background: var(--moonwake-dim); color: var(--moonwake); border: 1px solid rgba(127, 216, 204, 0.4); }
.statusBadgeErr { background: var(--flare-dim); color: var(--flare); border: 1px solid rgba(255, 110, 90, 0.4); }
.statusBadgeWarn { background: var(--dock-lamp-dim); color: var(--dock-lamp); border: 1px solid rgba(232, 162, 59, 0.4); }

.actionsCell { display: flex; gap: 6px; flex-wrap: wrap; }
.smallBtn {
  background: var(--undertow-2); border: 1px solid var(--foam-15); color: var(--foam);
  border-radius: var(--radius-sm); padding: 5px 10px; font-size: 12px; cursor: pointer;
}
.smallBtn:hover { border-color: var(--foam-35); }
.dangerBtnSmall { border-color: rgba(255, 110, 90, 0.4); color: var(--flare); }
.dangerBtnSmall:hover { background: var(--flare-dim); }

.provisionForm { display: flex; flex-direction: column; gap: 10px; max-width: 420px; }
.provisionForm label { font-size: 12.5px; color: var(--foam-60); }
.provisionForm input {
  padding: 10px 12px; border-radius: var(--radius-sm); border: 1px solid var(--foam-15);
  background: var(--undertow-2); color: var(--foam); font-size: 14px; font-family: var(--font-body);
}
.provisionForm input:focus { border-color: var(--moonwake); }
.provisionForm button[type="submit"] {
  margin-top: 4px; background: var(--dock-lamp); color: var(--deep-water); border: none;
  border-radius: var(--radius-sm); padding: 11px 16px; font-size: 14px; font-weight: 600; cursor: pointer;
}
.provisionForm button[type="submit"]:hover { filter: brightness(1.08); }

.resultBox {
  margin-top: 14px; padding: 12px 14px; border-radius: var(--radius-sm);
  background: var(--undertow-2); border: 1px solid var(--foam-15);
  font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; line-height: 1.5;
}

.modalOverlay {
  position: fixed; inset: 0; background: rgba(10, 20, 32, 0.75);
  display: flex; align-items: center; justify-content: center; z-index: var(--z-stage-fs); padding: 20px;
}
/* A class selector's display:flex outranks the UA stylesheet's [hidden]
   rule, so toggling .hidden in JS did nothing and the confirm modal was
   permanently stuck on screen with no way to dismiss it. Restore it. */
.modalOverlay[hidden] { display: none; }
.modalCard {
  background: var(--undertow); border: 1px solid var(--foam-15); border-radius: var(--radius-lg);
  padding: 22px; max-width: 380px; width: 100%;
}
.modalCard h3 { margin: 0 0 10px; font-size: 16px; }
.modalCard p { font-size: 13.5px; color: var(--foam-60); line-height: 1.5; margin: 0 0 14px; }
.modalCard input {
  width: 100%; padding: 9px 12px; border-radius: var(--radius-sm); border: 1px solid var(--foam-15);
  background: var(--undertow-2); color: var(--foam); font-size: 14px; margin-bottom: 14px;
}
.modalActions { display: flex; justify-content: flex-end; gap: 10px; }
.dangerBtn {
  background: var(--flare); color: var(--deep-water); border: none;
  border-radius: var(--radius-sm); padding: 9px 16px; font-size: 13.5px; font-weight: 600; cursor: pointer;
}
.dangerBtn:disabled { opacity: 0.4; cursor: not-allowed; }
.dangerBtn:hover:not(:disabled) { filter: brightness(1.1); }

.adminToast {
  position: fixed; bottom: calc(20px + var(--safe-b)); left: 50%; transform: translateX(-50%);
  background: var(--undertow); border: 1px solid var(--foam-15); border-radius: var(--radius-md);
  padding: 12px 18px; font-size: 13px; max-width: 90vw; z-index: var(--z-toast); box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.adminToast.toastError { border-color: rgba(255, 110, 90, 0.5); color: var(--flare); }
