﻿/* ============================================================
   Luma-style monochrome glass UI
   ============================================================
   Design notes:
   â€¢ Single hue â€” pure neutral grayscale. No color accents.
   â€¢ Panels: deeply blurred (28px) with very low-opacity fills.
   â€¢ Hairline borders only (1px @ ~6% white).
   â€¢ Typography: light weight, wide tracking on labels, mono for nums.
   â€¢ Hover/active: subtle brightness shift, never a color change.
   â€¢ Annotation hotspots & hand cursor: white-on-glass.
   ============================================================ */

:root {
  --bg:           #000000;
  --panel:        rgba(18, 18, 20, 0.55);
  --panel-strong: rgba(22, 22, 24, 0.75);
  --hair:         rgba(255, 255, 255, 0.06);
  --hair-strong:  rgba(255, 255, 255, 0.12);
  --text:         rgba(255, 255, 255, 0.96);
  --text-mid:     rgba(255, 255, 255, 0.62);
  --text-dim:     rgba(255, 255, 255, 0.40);
  --text-faint:   rgba(255, 255, 255, 0.22);
  --hover:        rgba(255, 255, 255, 0.04);
  --hover-strong: rgba(255, 255, 255, 0.08);
  --shadow-soft:  0 8px 32px rgba(0, 0, 0, 0.45);

  --font-ui: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text",
             "Segoe UI", Roboto, ui-sans-serif, system-ui, sans-serif;
  --font-mono: "JetBrains Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace;

  --radius:       12px;
  --radius-sm:    8px;
  --pad:          14px;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  width: 100%;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-ui);
  font-weight: 400;
  font-feature-settings: "ss01", "cv11";
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow: hidden;
  user-select: none;
}

#app   { position: fixed; inset: 0; }
#viewport {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  outline: none;
  touch-action: none;
}

/* ============ Annotation hotspots (Sketchfab-style markers) ============ */
#annotation-layer {
  position: absolute; inset: 0;
  pointer-events: none;
  z-index: 10;
}
.annotation {
  /* Promoted from <div> to <button> for a11y. Reset native button chrome
     so the visual is identical to the original div. */
  border: 0;
  padding: 0;
  font-family: inherit;
  appearance: none;
  position: absolute;
  width: 24px;
  height: 24px;
  margin-left: -12px;
  margin-top: -12px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.92);
  color: #0a0a0b;
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  pointer-events: auto;
  box-shadow:
    0 0 0 1px rgba(0, 0, 0, 0.6),
    0 0 0 4px rgba(255, 255, 255, 0.10),
    0 4px 14px rgba(0, 0, 0, 0.5);
  transition: transform 0.18s cubic-bezier(.2,.7,.3,1), box-shadow 0.18s;
  will-change: transform;
}
.annotation:hover {
  transform: scale(1.15);
  box-shadow:
    0 0 0 1px rgba(0, 0, 0, 0.6),
    0 0 0 6px rgba(255, 255, 255, 0.16),
    0 6px 22px rgba(0, 0, 0, 0.55);
}
.annotation.occluded { opacity: 0.32; }

/* ============ Data-Label overlay (surveillance aesthetic) ============ */
.data-overlay {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 11;
}
.data-overlay .bbox-line {
  stroke: rgba(255, 255, 255, 0.18);
  stroke-width: 1;
  stroke-dasharray: 4 5;
}
.data-overlay .tick-cross {
  stroke: rgba(255, 255, 255, 0.55);
  stroke-width: 1;
  fill: none;
}
.data-overlay .tick-text {
  fill: rgba(255, 255, 255, 0.50);
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.04em;
}
.data-overlay .connector {
  stroke: rgba(255, 255, 255, 0.35);
  stroke-width: 1;
}

.data-cards-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 12;
  will-change: transform;
}
.data-card {
  position: absolute;
  left: 0; top: 0;
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(8px) saturate(140%);
  -webkit-backdrop-filter: blur(8px) saturate(140%);
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 3px;
  padding: 6px 10px;
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.04em;
  white-space: nowrap;
  display: flex;
  flex-direction: column;
  gap: 1px;
  will-change: transform;
}
.data-card .card-row {
  display: flex;
  gap: 8px;
}
.data-card .k {
  color: rgba(255, 255, 255, 0.42);
  width: 40px;
  text-transform: uppercase;
}
.data-card .v {
  color: rgba(255, 255, 255, 0.92);
}

/* ============ Left stack: viewpoints + scene layers ============ */
#left-stack {
  position: absolute;
  top: 18px;
  left: 18px;
  bottom: 100px;            /* leave room for hand panel */
  width: 240px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  /* NO `z-index` declared on purpose â€” `position: absolute + z-index`
     would create a stacking context, which in Chromium prevents
     descendants' `backdrop-filter` from "seeing" the 3D canvas at
     the root level (the blur effect collapses to a faint tint).
     Without z-index, #left-stack still paints above the canvas
     because it comes AFTER the canvas in DOM order, while its
     descendants' backdrop-filter can correctly reach back to the
     root composited scene. */
  pointer-events: none;     /* let viewport clicks pass through the gap */
}
#left-stack > * { pointer-events: auto; }

/* ============ Sidebar â€” Viewpoints ============ */
#sidebar {
  position: static;         /* now flex child of #left-stack */
  width: 100%;
  background: var(--panel);
  backdrop-filter: blur(28px) saturate(140%);
  -webkit-backdrop-filter: blur(28px) saturate(140%);
  border: 1px solid var(--hair);
  border-radius: var(--radius);
  display: flex;
  flex-direction: column;
  flex: 0 1 auto;
  min-height: 0;
  max-height: 60%;          /* shares space with #scene-panel below */
  overflow: hidden;
  box-shadow: var(--shadow-soft);
  /* Shrink-in-place transition for the collapse / expand motion.
     IMPORTANT: do NOT declare `transition: transform` here â€” Chromium
     promotes any element with that declaration onto its own GPU
     compositor layer, and once it's a separate layer the panel's own
     backdrop-filter can no longer reach the scene behind it (shows
     as faint / broken glass). We animate the actual box-model
     properties we use (max-width / max-height / padding) instead. */
  transition: max-width 0.28s cubic-bezier(.22, 1, .36, 1),
              max-height 0.28s cubic-bezier(.22, 1, .36, 1),
              min-height 0.28s cubic-bezier(.22, 1, .36, 1),
              padding 0.28s cubic-bezier(.22, 1, .36, 1);
}
/* Collapsed state â€” the panel SHRINKS IN PLACE to a 38 Ã— 38 chevron
   tab. No floating-handle trick: the affordance lives at the panel's
   exact Y coordinate inside #left-stack, so multiple collapsed panels
   stack naturally and the "expand" tap target is always co-located
   with where the panel was. The slim tab IS the affordance, and the
   same chevron toggle (the only visible child when collapsed) handles
   both directions â€” flipped 180Â° via CSS to read as "expand".
   Width changes via max-width so the smooth height transition pairs
   nicely with the horizontal shrink. */
#sidebar.sidebar-minimized,
#scene-panel.sidebar-minimized {
  max-height: 38px;
  max-width: 38px;
  width: 38px;
  min-height: 38px;
  padding: 0;
  overflow: hidden;
}
#sidebar.sidebar-minimized header,
#scene-panel.sidebar-minimized header {
  padding: 0;
  border: 0;
  height: 38px;
  justify-content: center;
}
#sidebar.sidebar-minimized header .title,
#sidebar.sidebar-minimized header #add-viewpoint,
#sidebar.sidebar-minimized #viewpoint-list,
#sidebar.sidebar-minimized .hint,
#scene-panel.sidebar-minimized header .title,
#scene-panel.sidebar-minimized header .add-splat,
#scene-panel.sidebar-minimized .layer-list,
#scene-panel.sidebar-minimized .hint {
  display: none;
}
#sidebar.sidebar-minimized .sidebar-toggle,
#scene-panel.sidebar-minimized .sidebar-toggle {
  width: 38px;
  height: 38px;
  margin: 0;
}
#sidebar.sidebar-minimized .sidebar-toggle svg,
#scene-panel.sidebar-minimized .sidebar-toggle svg {
  transform: rotate(180deg);
}
#scene-panel {
  /* Same shrink-in-place transition as #sidebar â€” no `transform` in
     the transition list (would break backdrop-filter, see #sidebar). */
  transition: max-width 0.28s cubic-bezier(.22, 1, .36, 1),
              max-height 0.28s cubic-bezier(.22, 1, .36, 1),
              min-height 0.28s cubic-bezier(.22, 1, .36, 1),
              padding 0.28s cubic-bezier(.22, 1, .36, 1);
}
/* Scene panel header â€” same flex shape as sidebar header so the new
   chevron, the title, and "+ Add" align. */
#scene-panel header {
  display: flex;
  align-items: center;
  gap: 8px;
}
#scene-panel header .title { flex: 1; }
#scene-panel #scene-toggle.sidebar-toggle {
  /* Reuse the same chevron styling defined for #sidebar's toggle. */
  background: transparent;
  border: 0;
  color: var(--text-mid);
  width: 26px;
  height: 26px;
  border-radius: 6px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  padding: 0;
  transition: background 0.15s ease, color 0.15s ease;
}
#scene-panel #scene-toggle.sidebar-toggle:hover { background: rgba(255, 255, 255, 0.06); color: var(--text); }
#scene-panel #scene-toggle.sidebar-toggle svg   { width: 16px; height: 16px; display: block; }
/* Collapse / expand chevron â€” lives inside #sidebar header. The SAME
   button toggles in both directions: when the sidebar collapses, the
   panel shrinks to a 38 Ã— 38 tab around this chevron (rotated 180Â°
   via CSS) so the affordance is always in place. */
#sidebar .sidebar-toggle {
  background: transparent;
  border: 0;
  color: var(--text-mid);
  width: 26px;
  height: 26px;
  border-radius: 6px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  padding: 0;
  margin-right: 4px;
  transition: background 0.15s ease, color 0.15s ease;
}
#sidebar .sidebar-toggle:hover { background: rgba(255, 255, 255, 0.06); color: var(--text); }
#sidebar .sidebar-toggle svg { width: 16px; height: 16px; display: block; }

/* (Floating .panel-expand-handle is gone â€” the collapsed panel IS the
   expand affordance now. See the .sidebar-minimized / .hand-minimized
   rules above for the slim-tab-in-place idiom.) */

#sidebar header {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 8px;
  padding: 12px 14px 10px;
  border-bottom: 1px solid var(--hair);
}
#sidebar header .title {
  flex: 1;
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-mid);
}
#sidebar button {
  background: transparent;
  color: var(--text);
  border: 1px solid var(--hair-strong);
  border-radius: 6px;
  padding: 4px 10px;
  cursor: pointer;
  font-family: var(--font-ui);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.02em;
  transition: background 0.12s, border-color 0.12s;
}
#sidebar button:hover {
  background: var(--hover-strong);
  border-color: rgba(255, 255, 255, 0.22);
}
/* Viewpoints header â€” both action buttons (Share + Add) use the same
   icon-only square pill. Equal padding, equal hit-target, same hover
   treatment. Previously the +Add button was text-bearing and the Share
   was icon-only â€” the asymmetry read as unfinished. */
#sidebar .vp-header-btn {
  padding: 4px 6px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--text-mid);
}
#sidebar .vp-header-btn:hover { color: var(--text); }
#sidebar .vp-header-btn svg   { display: block; }
#sidebar.sidebar-minimized header .vp-header-btn { display: none; }
#viewpoint-list {
  list-style: none;
  margin: 0;
  padding: 6px;
  overflow-y: auto;
  flex: 1;
}
#viewpoint-list li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 13px;
  font-weight: 400;
  color: var(--text);
  transition: background 0.12s;
}
#viewpoint-list li:hover  { background: var(--hover); }
#viewpoint-list li.active { background: var(--hover-strong); }
#viewpoint-list li .badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px; height: 20px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.06);
  color: var(--text-mid);
  font-size: 10px;
  font-weight: 500;
  font-family: var(--font-mono);
  transition: background 0.12s, color 0.12s;
}
#viewpoint-list li.active .badge {
  background: rgba(255, 255, 255, 0.92);
  color: #0a0a0b;
}
#viewpoint-list li .name {
  flex: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  letter-spacing: 0.01em;
}
#viewpoint-list li .del {
  visibility: hidden;
  color: var(--text-dim);
  background: transparent;
  border: none;
  font-size: 16px;
  cursor: pointer;
  padding: 0 4px;
  line-height: 1;
}
#viewpoint-list li:hover .del { visibility: visible; }
#viewpoint-list li .del:hover { color: var(--text); }

#sidebar .hint {
  border-top: 1px solid var(--hair);
  padding: 10px 14px;
  font-size: 9.5px;
  color: var(--text-dim);
  line-height: 1.6;
  letter-spacing: 0.02em;
}
/* Each hint row is a single physical row of content (icon + kbd shortcuts
   + descriptors). At the panel's 240 px width the longer rows wrap.
   Previously used `display: block` + `text-indent: -18px` hanging indent,
   but that broke down when wrapping landed in the middle of a text
   node between two <kbd> elements â€” characters at the wrap boundary
   got clipped by the parent's overflow:hidden, producing "mov" /
   "down/u" / "viewpoin" garbage. Flex-wrap with proper inline gap
   handles the same case cleanly: every child (icon, kbd, text) gets
   a real baseline + a real gap, and wrapped lines all start at the
   parent padding-left (no negative-indent magic). */
#sidebar .hint .hint-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 3px 6px;
  margin: 3px 0;
}
#sidebar .hint .hint-key {
  font-size: 10.5px;
  display: inline-block;
  width: 14px;
  margin-right: 4px;
  filter: grayscale(1) brightness(2);
  opacity: 0.7;
  vertical-align: -1px;
}
#sidebar .hint kbd { margin: 0 1px; }
/* Hide keyboard + mouse shortcut hints on phone devices â€” there's no
   physical keyboard, and listing WASD / Q / E / V / R alongside the
   viewpoints reads as misleading clutter. Bound to `body.phone-device`
   (sticky, hardware-based) so this survives orientation changes â€”
   the existing `body.mobile #sidebar { display: none }` rule only
   hides the sidebar in PORTRAIT (mobile flips off when an iPhone
   rotates to landscape and the viewport width crosses 768 px),
   leaving the hints visible in landscape. */
body.phone-device #sidebar .hint { display: none; }
/* Same logic for the Scene panel's drag-and-drop hint â€” phones can't
   really drag files onto the canvas (the "+ Add" file picker and the
   Studio panel's "Use My Own" pill cover the same job on touch), and
   the hint footer was eating ~50 px of an already-short panel. Hiding
   it reclaims that height for the actual layer list. */
body.phone-device #scene-panel .hint { display: none; }

/* ============================================================
   Phone LANDSCAPE â€” #left-stack is only â‰ˆ 312 px tall (viewport
   height â‰ˆ 430 minus 18 px top + 100 px bottom buffer for the
   hand-panel). The default 60 / 40 split between Viewpoints and
   Scene gives Scene only â‰ˆ 125 px, of which 38 px is the header
   and the layer list ends up clamped to â‰ˆ 37 px â€” too short to
   show even one full row, hence the user's report of "scene éƒ¨åˆ†
   ä¸æ˜¾ç¤º" (Scene contents not displaying).

   Rebalance for that orientation:
   â€¢ Viewpoints (#sidebar) â€” hints are already hidden on
     body.phone-device, so the panel only ever holds the 3-item
     default viewpoint list. 36 % is plenty (â‰ˆ 112 px â†’ header
     38 + 3 rows â‰ˆ 108 fits within scroll).
   â€¢ Scene (#scene-panel) â€” the panel users actually read; let
     it own most of the column.
   The bound applies only to short landscape viewports so it
   doesn't perturb iPad / desktop. body.phone-device anchors to
   the device (sticky), not the orientation-derived `.mobile`
   class (which is OFF in this exact case), so the rule fires
   whether the body has `.tablet` or `.phone` attached. */
@media (orientation: landscape) and (max-height: 520px) {
  body.phone-device #sidebar     { max-height: 36%; }
  body.phone-device #scene-panel { max-height: 64%; }
}

/* ============================================================
   Phone LANDSCAPE â€” comprehensive size + layout pass.
   In landscape the viewport is wide-but-short (â‰ˆ 932 Ã— 430 on
   iPhone 14 PM, â‰ˆ 844 Ã— 390 on smaller phones). The default
   desktop-derived layout (used because `body.mobile` flips off
   above 768 px) stacks the Viewpoints / Scene / Hand-Tracking
   panels on the left, the lil-gui Studio on the right, plus
   any open hover card / Quick Guide / USD annotation card in
   between â€” together they easily occupy 100 % of the short
   viewport with multiple overlaps (the user's screenshot
   showed exactly this).

   Scoped to `body.phone-device` so iPad + desktop are
   untouched. Strategy:
     â€¢ Shrink lil-gui to a narrow right-rail
     â€¢ Shrink Viewpoints / Scene to a slim left-rail
     â€¢ Hand-Tracking starts collapsed (less footprint by default)
     â€¢ USD annotation card narrows + pulls closer to right edge
     â€¢ Asset hover card stays at its 0.72 zoom (existing rule)
     â€¢ Quick Guide narrows so it doesn't span the viewport
   ============================================================ */
@media (orientation: landscape) and (max-height: 520px) {
  /* lil-gui Studio â€” narrow rail on the right, not the full
     280-px desktop width. Tight max-height so it never spills
     past the viewport's bottom edge. */
  body.phone-device .lil-gui.root {
    width: 220px !important;
    max-width: 220px !important;
    top: 12px !important;
    right: 12px !important;
    bottom: auto !important;
    max-height: calc(100vh - 24px) !important;
    --width: 220px !important;
    --font-size: 10.5px !important;
  }
  /* Left rail â€” Viewpoints + Scene panels narrower so they
     don't eat the centre of the viewport. */
  body.phone-device #left-stack {
    width: 180px;
    top: 12px;
    left: 12px;
    bottom: 78px;
  }
  /* Hand Tracking panel â€” starts visually minimised in
     landscape so it doesn't dominate the short viewport. The
     pill remains tappable to expand. */
  body.phone-device #hand-panel {
    width: 180px;
    left: 12px;
    bottom: 12px;
  }
  /* Phone-landscape collapsed state â€” without this override the
     `body.phone-device #hand-panel { width: 180px }` rule above wins
     specificity over the generic `#hand-panel.hand-minimized { width: 38px }`
     declaration, leaving a stranded 180-px-wide empty olive rectangle
     when the user taps the chevron to collapse. User report:
     "æ‰‹æœºç«¯æ¨ªå±çš„æ”¶çº³è¿˜æ˜¯æ²¡åšå¥½ ... HANDTRACKING ui". Pin the
     minimized state to a clean 36Ã—36 pill so the collapse reads as
     "tab IS the affordance" the same way it does on desktop. */
  body.phone-device #hand-panel.hand-minimized {
    width: 36px !important;
    height: 36px !important;
    padding: 0 !important;
  }
  body.phone-device #hand-panel.hand-minimized .hand-head,
  body.phone-device #hand-panel.hand-minimized #hand-min-toggle.sidebar-toggle {
    width: 36px !important;
    height: 36px !important;
  }
  /* USD annotation card â€” narrower + pulls in toward the right
     edge so it sits to the LEFT of the narrowed lil-gui rather
     than overlapping the viewport centre. */
  #usd-annotations {
    width: min(280px, calc(100vw - 280px));
    right: 244px;       /* 220 (gui) + 12 (gui right offset) + 12 gap */
    top: 12px;
  }
  /* Quick Guide â€” narrower so it stops spanning the whole
     viewport in landscape. The default 2-column section grid
     (`.kh-body { grid-template-columns: repeat(2, 1fr) }`) + the
     110-px-wide key column inside each `.kh-row` left only â‰ˆ 2 px
     for the label text at 280 px card width â€” the descriptors
     ("Numbered viewpoints", "Pipeline drawer", "This guide")
     escaped the card visually. In landscape we collapse the
     section grid to a single column AND shrink the key column,
     so the rows fit cleanly without spillover. */
  body.phone-device #key-hints {
    max-width: 280px !important;
    bottom: 12px !important;
    /* Cap the panel height so it never grows past the top of a short
     * landscape viewport. Without this, the 4-section content stacked
     * vertically was taller than the ~400 px height of a phone in
     * landscape and the top half of the Quick Guide ended up off-screen
     * (or behind the top hamburger / about pill). The cap leaves
     * 12 px of breathing room at the top + bottom, and overflow-y:
     * auto turns the body into a scroll surface inside the chrome. */
    max-height: calc(100dvh - 24px) !important;
    overflow-y: auto !important;
    -webkit-overflow-scrolling: touch;
  }
  /* Quick Guide on phone landscape â€” single-column flow (the new desktop
     layout already does this <700 px), tighter vertical rhythm so the
     section breaks survive the short viewport. The key column auto-sizes
     to its content; rows + sections sit in a clear top-to-bottom read. */
  body.phone-device #key-hints .kh-body {
    display: flex !important;
    flex-direction: column !important;
    gap: 12px !important;
    padding: 10px 12px 12px !important;
  }
  body.phone-device #key-hints .kh-row {
    grid-template-columns: minmax(60px, auto) 1fr !important;
    font-size: 10.5px !important;
    gap: 10px !important;
  }
  body.phone-device #key-hints .kh-sec-title {
    font-size: 9.5px !important;
    margin-bottom: 6px !important;
  }
  body.phone-device #key-hints .kh-list {
    gap: 4px !important;
    padding-left: 8px !important;
  }
  /* USD annotation card text â€” slightly smaller in landscape
     so the narrower card still fits the intro + 2Ã—2 grid
     comfortably. */
  #usd-annotations .ua-title    { font-size: 20px; }
  #usd-annotations .ua-hero     { font-size: 18px; }
  #usd-annotations .ua-intro    { font-size: 11.5px; margin: 6px 0 10px; }
  #usd-annotations .ua-body     { font-size: 10.5px; }
  #usd-annotations .ua-tile     { padding: 8px 10px; }
  #usd-annotations .ua-tile-v   { font-size: 11px; }
  #usd-annotations .ua-grid     { gap: 6px; margin-bottom: 10px; }

  /* ---- Further compression of the left rail panels ----
     The user's screenshot shows Viewpoints clipped after 2 items
     and Scene barely fitting one layer row. Squeeze every visual
     by ~25 % so 3 viewpoints + the layer list fit cleanly in
     the â‰ˆ 340-px tall #left-stack. */
  body.phone-device #sidebar header,
  body.phone-device #scene-panel header {
    padding: 8px 10px 6px !important;
  }
  body.phone-device #sidebar header .title,
  body.phone-device #scene-panel header .title {
    font-size: 9px !important;
    letter-spacing: 0.16em !important;
  }
  body.phone-device #sidebar #add-viewpoint,
  body.phone-device #scene-panel .add-splat {
    padding: 3px 8px !important;
    font-size: 10px !important;
  }
  body.phone-device #viewpoint-list {
    padding: 4px !important;
  }
  body.phone-device #viewpoint-list li {
    padding: 5px 8px !important;
    font-size: 11.5px !important;
    gap: 8px !important;
  }
  body.phone-device #viewpoint-list li .badge {
    width: 17px !important; height: 17px !important;
    font-size: 9px !important;
  }
  body.phone-device #scene-panel .layer-list {
    padding: 4px !important;
  }
  body.phone-device #scene-panel .layer {
    padding: 5px 8px !important;
    font-size: 11px !important;
    gap: 7px !important;
  }
  body.phone-device #scene-panel .layer .eye {
    width: 17px !important; height: 17px !important;
  }
  /* Hand panel â€” tighter padding so the toggle pill takes the
     full panel width without dead space around it. */
  body.phone-device #hand-panel {
    padding: 8px !important;
  }
  body.phone-device #hand-toggle {
    padding: 7px 10px !important;
    font-size: 11px !important;
  }
}
/* Soft empty-state for the viewpoint list â€” when seed defaults haven't
   landed yet (or the user has deleted every saved viewpoint), the OL
   collapses to its 6 px padding and the panel reads as broken: header
   â†’ empty stripe â†’ hints. This pseudo-element fills the space with a
   short prompt that explains the affordance. The OL still has `flex: 1`
   so the placeholder occupies the natural list slot â€” no layout shift
   when a real viewpoint LI gets prepended. */
#viewpoint-list:empty {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 64px;
  padding: 14px 18px;
}
#viewpoint-list:empty::before {
  content: "Press V or + Add to save the current view";
  font-size: 10.5px;
  color: var(--text-dim);
  text-align: center;
  line-height: 1.5;
  letter-spacing: 0.01em;
}

/* ============ Scene layers panel ============ */
#scene-panel {
  position: static;
  width: 100%;
  flex: 0 1 auto;
  min-height: 0;
  max-height: 40%;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--panel);
  backdrop-filter: blur(28px) saturate(140%);
  -webkit-backdrop-filter: blur(28px) saturate(140%);
  border: 1px solid var(--hair);
  border-radius: var(--radius);
  box-shadow: var(--shadow-soft);
}
/* ============================================================
   Adaptive heights â€” when one of the left-stack panels collapses
   to its 38 Ã— 38 tab, the sibling should reclaim the freed space
   instead of staying at its baseline `max-height: 40%` / 60% cap.
   Without these rules, collapsing Viewpoints leaves the Scene
   panel stuck at 40% of #left-stack â€” most of the column unused,
   the layer list visibly clipped a few rows in (this is exactly
   the bug the screenshot showed: ONE layer + hint, when there
   was room for several more rows below). The general-sibling
   combinator handles "Viewpoints collapsed â†’ Scene grows"; the
   :has() selector handles the reverse direction.
   ============================================================ */
#sidebar.sidebar-minimized ~ #scene-panel {
  max-height: 100%;
}
#left-stack:has(#scene-panel.sidebar-minimized) #sidebar {
  max-height: 100%;
}
#scene-panel header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 14px 10px;
  border-bottom: 1px solid var(--hair);
}
#scene-panel header .title {
  font-family: var(--font-ui);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-mid);
}
#scene-panel .add-splat,
#scene-panel .scene-min {
  background: transparent;
  color: var(--text);
  border: 1px solid var(--hair-strong);
  border-radius: 6px;
  padding: 4px 10px;
  cursor: pointer;
  font-family: var(--font-ui);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.02em;
  transition: background 0.12s, border-color 0.12s;
}
#scene-panel .scene-min {
  padding: 0;
  width: 22px;
  height: 22px;
  font-size: 14px;
  line-height: 1;
  margin-left: auto;
}
#scene-panel .add-splat:hover,
#scene-panel .scene-min:hover {
  background: var(--hover-strong);
  border-color: rgba(255, 255, 255, 0.22);
}
/* (A/B Compare button + divider CSS removed alongside the JS feature
   removal â€” see scene-layers.js for the rationale.) */
/* Animated minimize â€” list + hint collapse via max-height + opacity. */
#scene-panel .layer-list,
#scene-panel .hint {
  overflow: hidden;
  max-height: 800px;
  opacity: 1;
  transition: max-height 0.28s cubic-bezier(0.22, 1, 0.36, 1),
              opacity    0.18s ease,
              padding    0.28s cubic-bezier(0.22, 1, 0.36, 1),
              margin     0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
#scene-panel.minimized .layer-list,
#scene-panel.minimized .hint {
  max-height: 0;
  opacity: 0;
  padding-top: 0;
  padding-bottom: 0;
  margin: 0;
}
#scene-panel.minimized header { cursor: pointer; }
#scene-panel .layer-list {
  list-style: none;
  margin: 0;
  padding: 6px;
  overflow-y: auto;
  flex: 1;
}
#scene-panel .layer-list .empty {
  padding: 12px 10px;
  font-family: var(--font-ui);
  font-size: 11px;
  color: var(--text-dim);
  text-align: center;
  letter-spacing: 0.02em;
}
#scene-panel .layer {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 7px 8px;
  border-radius: var(--radius-sm);
  font-size: 12px;
  color: var(--text);
  transition: background 0.12s;
}
#scene-panel .layer:hover { background: var(--hover); }
#scene-panel .layer.hidden-layer .name,
#scene-panel .layer.hidden-layer .count { opacity: 0.40; }
#scene-panel .layer .eye {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px; height: 20px;
  border-radius: 4px;
  background: transparent;
  border: 1px solid var(--hair);
  color: var(--text);
  font-size: 11px;
  cursor: pointer;
  padding: 0;
  transition: background 0.12s, border-color 0.12s, color 0.12s;
}
#scene-panel .layer .eye:hover {
  background: var(--hover-strong);
  border-color: rgba(255, 255, 255, 0.22);
}
#scene-panel .layer.hidden-layer .eye {
  color: var(--text-dim);
  background: rgba(255, 255, 255, 0.02);
}
#scene-panel .layer .name {
  flex: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  letter-spacing: 0.01em;
  font-weight: 400;
}
#scene-panel .layer .count {
  font-family: var(--font-mono);
  font-size: 9.5px;
  color: var(--text-mid);
  font-variant-numeric: tabular-nums;
}
#scene-panel .layer .badge {
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: 0.10em;
  color: var(--text-dim);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
  padding: 1px 5px;
  border-radius: 3px;
}
#scene-panel .layer .del {
  visibility: hidden;
  color: var(--text-dim);
  background: transparent;
  border: none;
  font-size: 16px;
  cursor: pointer;
  padding: 0 4px;
  line-height: 1;
}
#scene-panel .layer:hover .del { visibility: visible; }
#scene-panel .layer .del:hover { color: var(--text); }
#scene-panel .hint {
  border-top: 1px solid var(--hair);
  padding: 8px 14px 10px;
  font-size: 10px;
  color: var(--text-dim);
  letter-spacing: 0.02em;
  line-height: 1.5;
}

kbd {
  display: inline-block;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--hair);
  border-radius: 4px;
  padding: 1px 5px;
  font-family: var(--font-mono);
  font-size: 9.5px;
  color: var(--text-mid);
  line-height: 1.3;
}

/* ============ Hand-tracking gesture toast ============
 * Appears at the top-centre of the viewport for ~6 s after the user
 * toggles tracking ON. Teaches the gestures at the moment of relevance
 * â€” replaces the permanent sidebar hint rail rows that 99 % of visitors
 * never used. Glassy capsule to match the rest of the floating UI. */
#hand-tracking-tip {
  position: fixed;
  top: calc(70px + env(safe-area-inset-top, 0px));
  left: 50%;
  transform: translateX(-50%) translateY(-8px);
  z-index: 1700;
  pointer-events: auto;
  cursor: pointer;
  padding: 12px 18px;
  background: rgba(15, 16, 18, 0.92);
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: 14px;
  color: var(--text);
  font-family: var(--font-ui);
  backdrop-filter: blur(18px) saturate(150%);
  -webkit-backdrop-filter: blur(18px) saturate(150%);
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.22s ease, transform 0.22s ease, visibility 0.22s ease;
}
#hand-tracking-tip.show {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
}
#hand-tracking-tip .ht-title {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-mid);
  margin-bottom: 8px;
}
#hand-tracking-tip .ht-rows {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
#hand-tracking-tip .ht-row {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
}
#hand-tracking-tip .ht-glyph {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  font-size: 12px;
  color: rgba(255, 255, 255, 0.55);
}

/* ============ Hand tracking panel ============ */
/* Phone PORTRAIT only â€” hide the hand panel (camera-tracked input doesn't
   work well held vertically with one thumb, and the panel competes with
   the bottom-bar for screen real estate). In phone LANDSCAPE the body
   class flips to .tablet and the panel reappears at its landscape position
   (see the @media (orientation: landscape) block above which positions
   it at left:12px / bottom:12px / width:180px). Replaces the previous
   IS_PHONE init-time inline `style.display = "none"` that was sticky
   from the initial portrait detection and persisted into landscape,
   leaving hand tracking unreachable after rotation (user report:
   "æ‰‹æœºæ¨ªå±çš„handtrackingæ˜¾ç¤ºæœ‰é—®é¢˜"). */
body.phone:not(.tablet) #hand-panel { display: none !important; }

#hand-panel {
  position: absolute;
  left: 18px;
  bottom: 18px;
  background: var(--panel);
  backdrop-filter: blur(28px) saturate(140%);
  -webkit-backdrop-filter: blur(28px) saturate(140%);
  border: 1px solid var(--hair);
  border-radius: var(--radius);
  z-index: 20;
  padding: 10px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 224px;
  box-shadow: var(--shadow-soft);
  /* Shrink-in-place transition (no `transform` declared â€” would
     break backdrop-filter; see notes on #sidebar). */
  transition: width 0.28s cubic-bezier(.22, 1, .36, 1),
              height 0.28s cubic-bezier(.22, 1, .36, 1),
              padding 0.28s cubic-bezier(.22, 1, .36, 1);
}
/* Collapsed: panel shrinks in place to a 38 Ã— 38 chevron tab anchored
   at its bottom-left position. The absolute-positioned corner chevron
   pops static to fill the tab; everything else hides. Same "tab IS the
   affordance" idiom as the left-stack panels. */
#hand-panel.hand-minimized {
  width: 38px;
  height: 38px;
  padding: 0;
  overflow: hidden;
  gap: 0;
}
/* Hide everything except the header (which holds the chevron). */
#hand-panel.hand-minimized > :not(.hand-head) { display: none; }
#hand-panel.hand-minimized .hand-head {
  margin: 0;
  width: 38px;
  height: 38px;
  justify-content: center;
}
#hand-panel.hand-minimized #hand-min-toggle.sidebar-toggle {
  width: 38px;
  height: 38px;
}
#hand-panel.hand-minimized #hand-min-toggle.sidebar-toggle svg {
  transform: rotate(180deg);
  width: 16px;
  height: 16px;
}
/* Slim chevron-only strip at the top of #hand-panel. Dropped the
   uppercase "HAND TRACKING" label that used to sit next to the
   chevron â€” it duplicated the existing "Hand Tracking" text inside
   the toggle button below, which (a) read as visually redundant
   and (b) forced the toggle row narrower, clipping the "OFF" badge.
   Now the chevron sits alone at the top-left as a small affordance
   and the toggle button is the panel's primary visual surface. */
#hand-panel .hand-head {
  display: flex;
  align-items: center;
  height: 18px;
  margin: -2px 0 4px -2px;
}
#hand-panel #hand-min-toggle.sidebar-toggle {
  background: transparent;
  border: 0;
  color: var(--text-mid);
  width: 24px;
  height: 24px;
  border-radius: 6px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  padding: 0;
  transition: background 0.15s ease, color 0.15s ease;
}
#hand-panel #hand-min-toggle.sidebar-toggle:hover { background: rgba(255, 255, 255, 0.06); color: var(--text); }
#hand-panel #hand-min-toggle.sidebar-toggle svg   { width: 14px; height: 14px; display: block; }
/* Hand Tracking toggle â€” restored to the 04ad9c1 snapshot per
   user request. Single bordered pill with the hand icon + label +
   a small mono "ON / OFF / ERR" badge on the right. No "snap to
   white" active state, no iOS knob, no halo glow â€” the older
   treatment had a clearer visual identity. The .hand-head chevron
   header above this toggle is visible by default (collapse rule
   removed) so the user can fold the panel back down. */
#hand-toggle {
  display: flex;
  align-items: center;
  gap: 10px;
  background: transparent;
  border: 1px solid var(--hair-strong);
  border-radius: var(--radius-sm);
  padding: 8px 12px;
  cursor: pointer;
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.01em;
  transition: background 0.12s, border-color 0.12s;
}
#hand-toggle:hover { background: var(--hover-strong); border-color: rgba(255, 255, 255, 0.22); }
#hand-toggle .icon { font-size: 14px; filter: grayscale(1) brightness(2); }
#hand-toggle .label { flex: 1; text-align: left; }
#hand-toggle .state {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.08em;
  color: var(--text-dim);
  padding: 2px 7px;
  border-radius: 3px;
  background: rgba(255, 255, 255, 0.05);
}
#hand-toggle.active        { background: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.20); }
#hand-toggle.active .state { color: var(--text); background: rgba(255, 255, 255, 0.12); }
#hand-toggle.loading .state{ color: var(--text-mid); }
#hand-toggle.error         { border-color: rgba(255, 100, 100, 0.30); }
#hand-toggle.error .state  { color: #ff8a8a; background: rgba(255, 100, 100, 0.10); }

#hand-preview-wrap {
  display: none;
  width: 100%;
  aspect-ratio: 4 / 3;
  border-radius: var(--radius-sm);
  overflow: hidden;
  border: 1px solid var(--hair);
  background: #000;
  position: relative;
}
/* Landmark + skeleton overlay â€” sits on top of the webcam <video> so
   users see exactly what the tracker is locking onto. Pointer events
   pass through so it never steals interaction. Canvas drawing handled
   by src/hand-landmarks-overlay.js (rAF loop tied to the snapshot
   pushed in via HandController.onHandsUpdate). */
#hand-preview-wrap .hand-overlay-canvas {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 2;
}

/* Gesture HUD â€” small floating chip near the top of the viewport that
   surfaces the currently-interpreted gesture (ORBIT / ZOOMÂ·PAN / TAP /
   PINCH / PAINT). Hidden by default; toggled via .show by the gesture
   handlers in main.js. Uses the project's standard panel design tokens
   (var(--panel) / var(--hair) / var(--text)) so it reads as part of
   the same monochrome glass UI family as everything else, not an
   alien tinted callout. */
#hand-gesture-hud {
  position: fixed;
  top: 28px;
  left: 50%;
  transform: translateX(-50%) translateY(-6px);
  z-index: 1700;
  pointer-events: none;
  padding: 8px 18px;
  border-radius: 999px;
  background: var(--panel-strong);
  border: 1px solid var(--hair-strong);
  box-shadow: var(--shadow-soft);
  backdrop-filter: blur(20px) saturate(140%);
  -webkit-backdrop-filter: blur(20px) saturate(140%);
  opacity: 0;
  transition: opacity 0.22s ease, transform 0.22s cubic-bezier(.16, 1, .3, 1);
  font-family: var(--font-mono);
}
#hand-gesture-hud[hidden] { display: none; }
#hand-gesture-hud.show {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}
#hand-gesture-hud .hgh-label {
  color: var(--text);
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.22em;
  text-transform: uppercase;
}
#hand-toggle.active ~ #hand-preview-wrap { display: block; }
#hand-video {
  width: 100%; height: 100%;
  object-fit: cover;
  transform: scaleX(-1);
  filter: grayscale(0.35) contrast(1.05);
}

#hand-hint {
  display: none;
  font-size: 10px;
  color: var(--text-dim);
  line-height: 1.55;
  letter-spacing: 0.02em;
  padding: 2px 2px 0;
}
#hand-toggle.active ~ #hand-hint { display: block; }

.hand-status {
  font-size: 10px;
  color: var(--text-mid);
  font-family: var(--font-mono);
  min-height: 14px;
  letter-spacing: 0.04em;
}

/* Hand tracking error card â€” restored to the 04ad9c1 snapshot per
   user request. Simple red-tinted box, coral title, muted hint,
   small dashed-underline docs link. No warning icon, no Retry pill,
   no flex-row action footer. */
#hand-error {
  display: none;
  background: rgba(255, 100, 100, 0.06);
  border: 1px solid rgba(255, 100, 100, 0.20);
  border-radius: var(--radius-sm);
  padding: 9px 11px;
  font-size: 11px;
  line-height: 1.55;
  color: var(--text);
}
#hand-error:not([hidden]) { display: block; }
#hand-error .title {
  color: #ffb0b0;
  font-weight: 500;
  margin-bottom: 4px;
  letter-spacing: 0.01em;
}
#hand-error .hint { color: var(--text-mid); }
#hand-error .docs {
  display: inline-block;
  margin-top: 6px;
  color: var(--text);
  text-decoration: none;
  font-size: 10px;
  border-bottom: 1px dashed var(--hair-strong);
  padding-bottom: 1px;
}
#hand-error .docs:hover { color: var(--text); border-color: var(--text); }

/* ============ Hand virtual cursor ============ */
/* Drag-and-drop splat upload overlay */
#drop-zone {
  position: absolute;
  inset: 0;
  z-index: 200;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s ease-out;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(8px);
}
#drop-zone.active { opacity: 1; pointer-events: auto; }
#drop-zone .drop-ring {
  border: 2px dashed rgba(255, 255, 255, 0.55);
  border-radius: 16px;
  padding: 36px 48px;
  text-align: center;
  background: rgba(0, 0, 0, 0.40);
}
#drop-zone .drop-title {
  font-size: 14px;
  font-weight: 500;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--text);
  margin-bottom: 8px;
}
#drop-zone .drop-sub {
  font-size: 11px;
  color: var(--text-mid);
  letter-spacing: 0.02em;
}

/* ----------------------------------------------------------------------
 * Profiler â€” per-phase frame-time breakdown. Toggle via P. Luma-glass.
 * Sits above the bottom toolbar so it doesn't overlap on toggle.
 * -------------------------------------------------------------------- */
#profiler {
  position: absolute;
  bottom: 80px;
  right: 18px;
  width: 300px;
  z-index: 22;
  background: var(--panel);
  backdrop-filter: blur(28px) saturate(140%);
  -webkit-backdrop-filter: blur(28px) saturate(140%);
  border: 1px solid var(--hair);
  border-radius: var(--radius);
  box-shadow: var(--shadow-soft);
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 11px;
  letter-spacing: 0.01em;
  user-select: none;
  display: none;
}
#profiler.show { display: block; }
#profiler .prof-title {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px 10px;
  border-bottom: 1px solid var(--hair);
}
#profiler .prof-title .dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: rgba(255, 255, 255, 0.92);
  box-shadow: 0 0 8px rgba(255, 255, 255, 0.35);
}
#profiler .prof-title .t {
  flex: 1;
  font-family: var(--font-ui);
  font-weight: 500;
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-mid);
}
#profiler .prof-key {
  font-family: var(--font-mono);
  background: rgba(255, 255, 255, 0.06);
  padding: 1px 6px;
  border-radius: 3px;
  font-size: 9px;
  color: var(--text-mid);
}
#profiler .prof-body { padding: 8px 14px 12px; }
#profiler .prof-frame { padding: 4px 0; }
#profiler .prof-row {
  display: flex;
  justify-content: space-between;
  padding: 2px 0;
}
#profiler .prof-row .k {
  font-family: var(--font-ui);
  color: var(--text-mid);
  font-size: 10px;
  letter-spacing: 0.04em;
}
#profiler .prof-row .v {
  font-family: var(--font-mono);
  color: var(--text);
  font-size: 10.5px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}
#profiler .prof-frame-row .v { font-size: 14px; }
#profiler .prof-divider {
  border-top: 1px solid var(--hair);
  margin: 8px 0;
}
#profiler .prof-phase {
  margin-bottom: 8px;
}
#profiler .prof-phase-label {
  display: flex;
  justify-content: space-between;
  padding-bottom: 3px;
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.04em;
  color: var(--text-mid);
}
#profiler .prof-phase-bar {
  height: 3px;
  background: rgba(255, 255, 255, 0.06);
  border-radius: 2px;
  overflow: hidden;
}
#profiler .prof-phase-bar .fill {
  display: block;
  height: 100%;
  width: 0;
  background: rgba(255, 255, 255, 0.62);
  transition: width 0.18s ease;
}

/* ----------------------------------------------------------------------
 * Pipeline â€” right-side slide-in. Information-design centerpiece for the
 * showcase: assets, render primitives, capture & train, etc. Same
 * Luma-glass language as the sidebar / hand panel / pipeline HUD.
 * -------------------------------------------------------------------- */
#tech-spec {
  position: absolute;
  inset: 0;
  z-index: 5000;          /* above lil-gui (1001) so it reads cleanly */
  display: none;
  font-family: var(--font-ui);
  color: var(--text);
  letter-spacing: 0.01em;
  pointer-events: none;   /* let drags through; .ts-panel re-enables itself */
}
#tech-spec.show { display: block; }
/* Backdrop is fully transparent AND click-through so the 3DGS scene reads
 * sharply AND stays draggable behind the panel. The drawer is a live doc,
 * not a modal â€” close via T / Esc / the Ã— button. */
#tech-spec .ts-backdrop {
  position: absolute;
  inset: 0;
  background: transparent;
  pointer-events: none;
}
#tech-spec .ts-panel {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: min(460px, 92vw);
  pointer-events: auto;   /* re-enable on top of the click-through parent */
  background: var(--panel-strong);
  backdrop-filter: blur(28px) saturate(140%);
  -webkit-backdrop-filter: blur(28px) saturate(140%);
  border-left: 1px solid var(--hair);
  box-shadow: var(--shadow-soft);
  display: flex;
  flex-direction: column;
  transform: translateX(40px);
  opacity: 0;
  transition: transform 0.22s ease, opacity 0.22s ease;
}
#tech-spec.show .ts-panel {
  transform: translateX(0);
  opacity: 1;
}
#tech-spec .ts-header {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 14px 16px 12px;
  border-bottom: 1px solid var(--hair);
}
#tech-spec .ts-title {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 1;
}
#tech-spec .ts-title .dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: rgba(255, 255, 255, 0.92);
  box-shadow: 0 0 8px rgba(255, 255, 255, 0.35);
}
#tech-spec .ts-title .t {
  font-family: var(--font-ui);
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--text);
}
#tech-spec .ts-key {
  font-family: var(--font-mono);
  background: rgba(255, 255, 255, 0.06);
  padding: 1px 6px;
  border-radius: 3px;
  font-size: 9px;
  color: var(--text-mid);
  letter-spacing: 0.10em;
}
#tech-spec .ts-close {
  appearance: none;
  background: transparent;
  border: 1px solid var(--hair-strong);
  border-radius: 4px;
  box-sizing: border-box;
  flex-shrink: 0;
  width: 28px;
  height: 28px;
  /* Explicit padding: 0 overrides the user-agent default (`1px 6px`
     on <button>) â€” that default crashed the box width with
     border-box sizing and forced the Ã— off-centre. */
  padding: 0;
  color: var(--text-mid);
  font-size: 18px;
  font-family: var(--font-ui);
  line-height: 1;
  /* flex centring so the Ã— glyph (whose font-metric centre is offset)
     sits geometrically centred in the box. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: background 0.12s, border-color 0.12s, color 0.12s;
}
#tech-spec .ts-close:hover {
  background: var(--hover-strong);
  border-color: rgba(255, 255, 255, 0.22);
  color: var(--text);
}
#tech-spec .ts-sub {
  padding: 10px 16px;
  font-size: 10px;
  color: var(--text-dim);
  border-bottom: 1px solid var(--hair);
  letter-spacing: 0.02em;
}

/* Table-of-contents pill row — sits between the sub-line and the
   scrolling body. Stays fixed at the top of the panel (it's OUTSIDE
   .ts-body, so doesn't move with the content scroll). Horizontally
   scrollable on phone so a long section list never wraps to a second
   row and never eats vertical space. Each pill is a button so it
   handles keyboard focus + activation natively. The scrollbar is
   hidden (the active-pill auto-centre handles "I can't see all the
   pills" on phone, and a visible scrollbar would compete with the
   pill chips for attention). */
#tech-spec .ts-toc {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 9px 14px;
  border-bottom: 1px solid var(--hair);
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  -ms-overflow-style: none;
  /* Touch — let horizontal swipe scroll the pills without the parent
     scroll context (drawer body) stealing the gesture. */
  overscroll-behavior-inline: contain;
  touch-action: pan-x;
}
#tech-spec .ts-toc::-webkit-scrollbar { display: none; }
#tech-spec .ts-toc-pill {
  appearance: none;
  flex: 0 0 auto;
  background: transparent;
  border: 1px solid var(--hair);
  border-radius: 999px;
  padding: 5px 11px;
  font-family: var(--font-ui);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--text-mid);
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
#tech-spec .ts-toc-pill:hover {
  background: rgba(255, 255, 255, 0.04);
  border-color: var(--hair-strong);
  color: var(--text);
}
#tech-spec .ts-toc-pill.active {
  background: rgba(255, 255, 255, 0.10);
  border-color: rgba(255, 255, 255, 0.32);
  color: var(--text);
}
#tech-spec .ts-toc-pill:focus-visible {
  outline: 1px solid rgba(255, 255, 255, 0.55);
  outline-offset: 1px;
}

#tech-spec .ts-body {
  flex: 1;
  overflow-y: auto;
  padding: 4px 0 8px;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.20) transparent;
}
#tech-spec .ts-body::-webkit-scrollbar { width: 6px; }
#tech-spec .ts-body::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.18);
  border-radius: 3px;
}
#tech-spec .ts-sec {
  padding: 16px 18px 14px;
  border-bottom: 1px solid var(--hair);
}
#tech-spec .ts-sec:last-child { border-bottom: none; }
#tech-spec .ts-sec-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 4px 0;
  cursor: pointer;
  user-select: none;
}
/* Layer prefix (L1 / L2 / L3) â€” sits at the top of the big section
 * title as a small bookend label that reads as "Layer 1 of 3". */
#tech-spec .ts-sec-num {
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.12em;
  color: var(--text-dim);
  min-width: 30px;
  text-align: left;
  align-self: flex-start;
  margin-top: 8px;
}
#tech-spec .ts-sec-name {
  flex: 1;
  font-family: var(--font-ui);
  font-size: 26px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text);
  line-height: 1.1;
}
#tech-spec .ts-sec-count {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--text-dim);
  white-space: nowrap;
}

/* Layer sections (Authoring / Pipeline / Runtime) â€” left accent rail */
#tech-spec .ts-sec-layer {
  border-left: 1px solid rgba(255, 255, 255, 0.18);
  padding-left: 17px;       /* original 18 minus the 1 px rail */
}

/* Toolchain chip row inside a layer header â€” readable when collapsed */
#tech-spec .ts-sec-tools {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 5px;
  padding: 4px 0 12px;
}
#tech-spec .ts-sec.collapsed .ts-sec-tools { display: none; }

/* Assets section â€” separator above so it visually breaks from the
 * three-layer stack */
#tech-spec .ts-sec-layer + .ts-sec-assets {
  margin-top: 8px;
  border-top: 1px solid var(--hair-strong);
  padding-top: 22px;
}

#tech-spec .ts-caret {
  font-size: 10px;
  color: var(--text-dim);
  transition: transform 0.18s;
}
#tech-spec .ts-sec.collapsed .ts-caret { transform: rotate(-90deg); }
#tech-spec .ts-sec.collapsed .ts-list,
#tech-spec .ts-sec.collapsed .ts-sec-desc { display: none; }
#tech-spec .ts-sec-desc {
  font-size: 12.5px;
  color: var(--text-mid);
  padding: 8px 0 16px;
  letter-spacing: 0.005em;
  line-height: 1.55;
}
#tech-spec .ts-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
/* ---- Item card ----
 * Visual hierarchy, top â†’ bottom:
 *   1. Title row    â€” h3 name + optional location chip
 *   2. Subtitle     â€” short technical tagline (ref)
 *   3. Toolchain    â€” labelled chip row (only for asset items)
 *   4. Output       â€” labelled value line
 *   5. Note         â€” readable prose paragraph
 *   6. Source       â€” small mono footer with a hairline rule above
 */
#tech-spec .ts-item {
  padding: 16px 0 18px;
  border-top: 1px solid var(--hair);
}
#tech-spec .ts-item:first-child { border-top: none; padding-top: 4px; }

/* Asset-level accordion. Each .ts-item-asset starts collapsed so the
 * Production list reads as a clean index of asset titles; the reader
 * clicks a title to expand the card. The head row gets a cursor + a
 * subtle hover hint + a caret on the right, mirroring how the section
 * head (.ts-sec-head) already telegraphs its click behaviour. The body
 * hides via display:none on the .collapsed parent. Non-asset items
 * don't get this treatment — they render inline as before. */
#tech-spec .ts-item-asset > .ts-item-head {
  cursor: pointer;
  user-select: none;
}
#tech-spec .ts-item-asset > .ts-item-head:hover .ts-item-name {
  color: #fff;
}
#tech-spec .ts-item-asset > .ts-item-head:focus-visible {
  outline: 1px solid var(--hair-strong);
  outline-offset: 2px;
  border-radius: 2px;
}
#tech-spec .ts-item-caret {
  align-self: flex-start;
  margin-top: 4px;
  font-size: 10px;
  color: var(--text-dim);
  transition: transform 0.18s ease;
}
/* Collapsed state rotates the caret like the section caret pattern,
 * and hides the wrapped body. The head row stays visible. */
#tech-spec .ts-item-asset.collapsed > .ts-item-head > .ts-item-caret {
  transform: rotate(-90deg);
}
#tech-spec .ts-item-asset.collapsed > .ts-item-body {
  display: none;
}
/* Expanded body — small padding-top so the head doesn't crowd the
 * first body row. */
#tech-spec .ts-item-asset > .ts-item-body {
  padding-top: 2px;
}

/* 1. Title row */
#tech-spec .ts-item-head {
  display: flex;
  align-items: flex-start;
  gap: 10px;
}
#tech-spec .ts-item-name {
  margin: 0;
  flex: 1;
  font-family: var(--font-ui);
  font-size: 16px;
  font-weight: 500;
  letter-spacing: 0.01em;
  line-height: 1.25;
  color: var(--text);
}
#tech-spec .ts-item-loc {
  align-self: flex-start;
  margin-top: 2px;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--text-dim);
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--hair-strong);
  padding: 3px 7px;
  border-radius: 3px;
  white-space: nowrap;
}

/* Hotspot ON/OFF pill â€” sits at the right edge of an asset item header.
 * Echoes ts-item-loc visually but is interactive: click toggles the
 * asset's floating dot in the scene. ON = bright green LED + white label;
 * OFF = dim red LED + muted label. */
#tech-spec .ts-hotspot-toggle {
  align-self: flex-start;
  margin-top: 2px;
  margin-left: auto;        /* push to the far right of the header row */
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--hair-strong);
  padding: 3px 8px;
  border-radius: 3px;
  white-space: nowrap;
  cursor: pointer;
  color: var(--text);
  transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
#tech-spec .ts-hotspot-toggle:hover {
  background: rgba(255, 255, 255, 0.10);
  border-color: rgba(255, 255, 255, 0.30);
}
#tech-spec .ts-hotspot-toggle .ts-toggle-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: #6bd779;
  box-shadow: 0 0 6px rgba(107, 215, 121, 0.65);
  transition: background 0.15s ease, box-shadow 0.15s ease;
}
#tech-spec .ts-hotspot-toggle[data-on="0"] {
  color: var(--text-dim);
  background: rgba(255, 255, 255, 0.02);
}
#tech-spec .ts-hotspot-toggle[data-on="0"] .ts-toggle-dot {
  background: rgba(255, 120, 120, 0.55);
  box-shadow: none;
}

/* 2. Subtitle â€” technical descriptor under the name (dimmer than the
 *    note paragraph so the body text doesn't compete with the title) */
#tech-spec .ts-item-sub {
  margin-top: 6px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.04em;
  color: var(--text-dim);
  line-height: 1.4;
}

/* 3 + 4. Labelled zone (Toolchain / Output) */
#tech-spec .ts-zone {
  margin-top: 14px;
}
#tech-spec .ts-zone-label {
  display: block;
  font-family: var(--font-mono);
  font-size: 9px;
  font-weight: 500;
  letter-spacing: 0.20em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 6px;
}
#tech-spec .ts-zone-val {
  font-family: var(--font-mono);
  font-size: 11.5px;
  letter-spacing: 0.01em;
  color: var(--text);
  line-height: 1.5;
}

/* Chip row inside the Toolchain zone */
#tech-spec .ts-chain {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 5px;
}
#tech-spec .ts-chip {
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.02em;
  color: var(--text);
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--hair-strong);
  padding: 4px 9px;
  border-radius: 5px;
  white-space: nowrap;
}
#tech-spec .ts-arrow {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text-faint);
  padding: 0 2px;
  user-select: none;
}

/* 5. Note â€” main reading paragraph; sized BELOW the title so it never
 *    out-weighs the headline */
#tech-spec .ts-item-note {
  margin: 14px 0 0;
  font-family: var(--font-ui);
  font-size: 12px;
  font-weight: 400;
  line-height: 1.55;
  letter-spacing: 0.005em;
  color: var(--text-mid);
}
#tech-spec .ts-item-note strong,
#tech-spec .ts-item-note b { color: var(--text); font-weight: 500; }

/* 6. Source â€” footnote with a hairline rule above */
#tech-spec .ts-item-src {
  margin-top: 14px;
  padding-top: 8px;
  border-top: 1px solid var(--hair);
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.04em;
  color: var(--text-dim);
}
/* External citation link inside an item's source footer (e.g., Tree
 * → Fab.com marketplace credit). Inherits the small mono dim styling
 * so the citation reads as metadata, with an underline + brightening
 * on hover so it's clearly clickable without breaking the monochrome
 * palette. */
#tech-spec .ts-item-src a {
  color: var(--text-mid);
  text-decoration: underline;
  text-decoration-color: var(--hair-strong);
  text-underline-offset: 2px;
  transition: color 0.15s ease, text-decoration-color 0.15s ease;
}
#tech-spec .ts-item-src a:hover {
  color: var(--text);
  text-decoration-color: var(--text);
}

/* 5b. Inline before/after compare widget â€” drag the handle to wipe.
 * Placeholder mode (no images yet) shows two subtly tinted panes so the
 * wipe action still reads. */
.ts-compare { margin-top: 16px; }
.ts-compare .cmp-frame {
  position: relative;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-sm);
  border: 1px solid var(--hair);
  overflow: hidden;
  cursor: ew-resize;
  user-select: none;
  -webkit-user-select: none;
  /* touch-action: none â€” fully claim every touch on the frame so the JS
     pointer handlers run reliably on iOS Safari + Chrome Android. The
     earlier `pan-y` was supposed to leave horizontal for JS while letting
     vertical scroll bubble up, but in practice mobile Safari raced its
     own gesture interpretation against pointermove and the slider often
     refused to drag at all (PM-report: "æ‰‹æœºç«¯AB compareä¼¼ä¹Žä¸èƒ½æ»‘åŠ¨").
     Matching the same `touch-action: none` every other JS drag surface
     in the project uses (canvas, asset-hotspot, lil-gui sliders) makes
     the drag reliable. Users can still scroll the parent drawer by
     touching outside the slider frame â€” the frames are 16:9 of the row
     width, not full-bleed. */
  touch-action: none;
  background: rgba(255, 255, 255, 0.03);
}
.ts-compare .cmp-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
.ts-compare .cmp-img-b { z-index: 1; }
.ts-compare .cmp-img-a { z-index: 2; clip-path: inset(0 50% 0 0); }
.ts-compare .cmp-ph {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--text-faint);
}
.ts-compare .cmp-ph.cmp-img-a {
  background: linear-gradient(135deg, rgba(190, 200, 220, 0.06), rgba(190, 200, 220, 0.02));
}
.ts-compare .cmp-ph.cmp-img-b {
  background: linear-gradient(135deg, rgba(255, 200, 130, 0.10), rgba(255, 160, 90, 0.04));
}
.ts-compare .cmp-handle {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 2px;
  margin-left: -1px;
  background: rgba(255, 255, 255, 0.85);
  z-index: 3;
  cursor: ew-resize;
  box-shadow:
    -2px 0 6px rgba(0, 0, 0, 0.45),
     2px 0 6px rgba(0, 0, 0, 0.45);
}
.ts-compare .cmp-knob {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 26px;
  height: 26px;
  margin: -13px;
  background: var(--panel-strong);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid rgba(255, 255, 255, 0.55);
  border-radius: 50%;
  cursor: ew-resize;
}
.ts-compare .cmp-knob::before {
  content: "⇆";
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text);
}
/* Caption row beneath the compare frame â€” replaces the old absolute-
   positioned .cmp-tag-a / .cmp-tag-b overlays that collided when both
   labels were long (e.g., "Before: Procedural base" + "After: AI-stylized
   oil paint" overlapping in the middle of a narrow side-by-side compare
   cell). Reads as a figure caption: left half = the "before" label,
   right half = the "after" label, gap in the middle so the two never
   touch even when the text wraps. */
.ts-compare .cmp-captions {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 12px;
  margin-top: 8px;
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  line-height: 1.4;
  color: var(--text-mid);
}
.ts-compare .cmp-cap {
  flex: 1 1 0;
  min-width: 0;
  /* No truncation â€” labels wrap onto multiple lines if needed so the
     full description always reads. The fixed-width flex-basis (1 1 0)
     keeps the A/B pair equal even when one line wraps and the other
     doesn't. */
  word-break: break-word;
}
.ts-compare .cmp-cap-a {
  text-align: left;
}
.ts-compare .cmp-cap-b {
  text-align: right;
}
#tech-spec .ts-footer {
  display: flex;
  justify-content: space-between;
  padding: 10px 16px 12px;
  border-top: 1px solid var(--hair);
  font-family: var(--font-mono);
  font-size: 9.5px;
}
#tech-spec .ts-foot-k {
  color: var(--text-dim);
  letter-spacing: 0.10em;
  text-transform: uppercase;
}
#tech-spec .ts-foot-v {
  color: var(--text-mid);
  letter-spacing: 0.02em;
}

/* Segmented toggles (Splat Subform, Hand Mode, etc.) â€” shared style */
.subform-toggle {
  display: flex;
  gap: 4px;
  margin: 2px 8px 4px 12px;
  padding: 2px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
  border-radius: var(--radius-sm);
}
.subform-toggle button {
  flex: 1;
  background: transparent;
  border: none;
  color: var(--text-mid);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.06em;
  padding: 5px 8px;
  border-radius: 4px;
  cursor: pointer;
  transition: background 0.12s, color 0.12s;
}
.subform-toggle button:hover { background: var(--hover); color: var(--text); }
.subform-toggle button.active {
  background: rgba(255, 255, 255, 0.10);
  color: var(--text);
}
/* Hover-popover wrapping each sub-form button â€” same aesthetic as .frustum-tip */
.subform-cell {
  position: relative;
  flex: 1;
  display: flex;
}
.subform-cell button { width: 100%; }
/* Portalled tooltips â€” appended to <body> by JS so position:fixed isn't
   broken by lil-gui's panel transform. Visibility toggled via .show class.
   z-index sits above every other overlay (Pipeline HUD, A/B Compare, etc.). */
.subform-tip {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9999;
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 10px;
  row-gap: 2px;
  padding: 8px 10px;
  background: rgba(8, 12, 18, 0.86);
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 4px;
  color: #d8dee6;
  font-family: var(--font-mono, monospace);
  font-size: 11px;
  letter-spacing: 0.04em;
  text-transform: none;
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.12s, visibility 0.12s;
  backdrop-filter: blur(4px);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
}
.subform-tip.show {
  opacity: 1 !important;
  visibility: visible !important;
}
.subform-tip .k {
  color: rgba(255, 255, 255, 0.45);
  text-transform: uppercase;
  font-size: 9.5px;
}
.subform-tip .v {
  color: #f0f3f6;
}

/* Hand-mode toggle (1-Hand / 2-Hand) â€” shown only when hand tracking is active */
#hand-mode {
  display: none;
  gap: 4px;
  padding: 2px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
  border-radius: var(--radius-sm);
}
#hand-toggle.active ~ #hand-mode { display: flex; }
#hand-mode button {
  flex: 1;
  background: transparent;
  border: none;
  color: var(--text-mid);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.06em;
  padding: 5px 8px;
  border-radius: 4px;
  cursor: pointer;
  transition: background 0.12s, color 0.12s;
}
#hand-mode button:hover { background: var(--hover); color: var(--text); }
#hand-mode button.active {
  background: rgba(255, 255, 255, 0.10);
  color: var(--text);
}

#hand-cursor,
#hand-cursor-2 {
  position: absolute;
  left: 0; top: 0;
  width: 60px; height: 60px;
  margin-left: -30px; margin-top: -30px;
  border-radius: 50%;
  pointer-events: none;
  z-index: 50;
  display: none;
  align-items: center;
  justify-content: center;
  border: 1.5px solid rgba(255, 255, 255, 0.92);
  background: radial-gradient(circle, rgba(255, 255, 255, 0.10) 0%, rgba(255, 255, 255, 0.0) 65%);
  box-shadow:
    0 0 16px rgba(255, 255, 255, 0.30),
    inset 0 0 10px rgba(255, 255, 255, 0.15);
  will-change: transform;
  animation: cursorIdlePulse 1.8s ease-in-out infinite;
}
#hand-cursor::before,
#hand-cursor-2::before {
  content: "";
  width: 6px; height: 6px;
  border-radius: 50%;
  background: #ffffff;
  box-shadow: 0 0 10px rgba(255, 255, 255, 0.85);
}
#hand-cursor::after,
#hand-cursor-2::after {
  content: "";
  position: absolute;
  inset: -7px;
  border-radius: 50%;
  border: 1px dashed rgba(255, 255, 255, 0.18);
  pointer-events: none;
}
#hand-cursor.active,
#hand-cursor-2.active { display: flex; }
#hand-cursor.pinch,
#hand-cursor-2.pinch {
  animation: cursorPinchPulse 0.4s ease-out;
  border-color: rgba(255, 230, 100, 0.95);
}
@keyframes cursorPinchPulse {
  0%   { transform: translate(var(--cx, 0), var(--cy, 0)) scale(1); box-shadow: 0 0 16px rgba(255, 230, 100, 0.30); }
  35%  { box-shadow: 0 0 44px rgba(255, 230, 100, 0.95), inset 0 0 22px rgba(255, 230, 100, 0.5); }
  100% { box-shadow: 0 0 16px rgba(255, 255, 255, 0.30), inset 0 0 10px rgba(255, 255, 255, 0.15); }
}
@keyframes cursorIdlePulse {
  0%, 100% { box-shadow: 0 0 16px rgba(255, 255, 255, 0.30), inset 0 0 10px rgba(255, 255, 255, 0.15); }
  50%      { box-shadow: 0 0 28px rgba(255, 255, 255, 0.55), inset 0 0 14px rgba(255, 255, 255, 0.25); }
}

/* ============ Toolbar (brand + status) ============ */
#toolbar {
  position: absolute;
  right: 18px;
  bottom: 18px;
  background: var(--panel);
  backdrop-filter: blur(28px) saturate(140%);
  -webkit-backdrop-filter: blur(28px) saturate(140%);
  border: 1px solid var(--hair);
  border-radius: var(--radius);
  padding: 9px 16px;
  display: flex;
  align-items: center;
  gap: 16px;
  z-index: 20;
  font-size: 11px;
  box-shadow: var(--shadow-soft);
}
#toolbar .brand {
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  font-size: 10px;
  color: var(--text);
  /* About-pill behavior â€” the brand text is now a button (see PM-6).
     Strip the native button chrome so it visually matches the old
     static label, but keep the click target obvious on hover. */
  background: transparent;
  border: 0;
  padding: 0;
  margin: 0;
  font-family: inherit;
  cursor: pointer;
  display: inline-flex;
  align-items: baseline;
  gap: 0.5em;
  transition: color 0.15s ease, opacity 0.15s ease;
}
#toolbar .brand:hover { color: var(--text); opacity: 1; }
#toolbar .brand .brand-about-mark,
#toolbar .brand .brand-about-label {
  color: var(--text-mid);
  font-weight: 400;
  letter-spacing: 0.18em;
  opacity: 0.85;
  transition: color 0.15s ease, opacity 0.15s ease;
}
#toolbar .brand:hover .brand-about-mark,
#toolbar .brand:hover .brand-about-label {
  color: var(--text);
  opacity: 1;
}
#toolbar #status {
  color: var(--text-mid);
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.02em;
}

/* ============================================================
   Editorial loading splash
   - Full-viewport dark overlay shown until the first splat lands.
   - Four corner annotations (museum / data-card aesthetic).
   - Center stack: large light-weight title, mono subtitle, hairline rule,
     two paragraphs of description, three numbered pillars.
   - Bottom: thin indeterminate progress bar + status text (kept under
     #loading-text so setLoading(msg) in main.js still drives it).
   ============================================================ */
#loading {
  position: absolute;
  inset: 0;
  z-index: 100;
  display: grid;
  grid-template-rows: auto 1fr auto;
  align-items: stretch;
  background:
    radial-gradient(120% 80% at 50% 35%, rgba(255, 255, 255, 0.04), transparent 60%),
    #050608;
  color: var(--text);
  font-family: var(--font-ui);
  letter-spacing: 0.01em;
  pointer-events: all;
  overflow: hidden;
  padding: 44px 44px 32px;
  opacity: 1;
  /* Initial fade-in on first paint; transition handles the fade-out once
   * .hidden lands (animation is forced off in the hidden state so it
   * doesn't fight the transition). */
  animation: ld-fadein 0.7s ease both;
  transition: opacity 0.5s ease, visibility 0s linear 0s;
}
#loading.hidden {
  animation: none;
  opacity: 0;
  pointer-events: none;
  visibility: hidden;
  transition: opacity 0.5s ease, visibility 0s linear 0.5s;
}
@keyframes ld-fadein {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* =============================================================
   Cinematic flourish â€” title card that fades in when the FBX
   camera-move emits "finished". Typography mirrors the LOADING
   SPLASH (.ld-title / .ld-sub / .ld-rule / .ld-desc) so the
   closing card reads as the typographic SIBLING of the opening
   one. Same hero word "SplatGarden" (mixed case, NOT all-caps),
   same -0.025em tight tracking, same 56-px centred hairline,
   same mono mid-caption recipe. No letter-spacing animation, no
   scale-out, no breathing â€” those flourishes felt theatrical and
   broke the project's quiet authority. Just a clean opacity
   fade in â†’ hold â†’ fade out.

   Tear-down: src/cinematic-flourish.js removes the element on
   sequence completion (or on dispose()), so a Replay-Intro can
   mount a fresh card without leftover state.
   ============================================================= */
.cinematic-flourish {
  position: fixed;
  inset: 0;
  z-index: 2000;            /* above lil-gui (1001) + everything else */
  pointer-events: none;     /* never blocks interaction */
  display: flex;
  align-items: center;
  justify-content: center;
  /* Soft radial vignette so the card has a quiet anchor against
     whatever splat colour happens to sit behind it â€” keeps the
     text legible without a heavy black overlay that would feel
     like a modal. */
  background: radial-gradient(50% 40% at 50% 50%, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0.0) 70%);
  opacity: 0;
  transition: opacity 0.7s ease;
  font-family: var(--font-ui);
  color: var(--text);
  will-change: opacity;
}
.cinematic-flourish.show     { opacity: 1; }
.cinematic-flourish.fade-out { opacity: 0; }

.cinematic-flourish .cf-stack {
  text-align: center;
  max-width: 720px;
  padding: 0 24px;
}

/* Title â€” same recipe as #loading .ld-title. NOT uppercase.
   Mixed-case "SplatGarden" with tight negative letter-spacing
   (the editorial display style the loading splash uses). */
.cinematic-flourish .cf-title {
  margin: 0;
  font-family: var(--font-ui);
  font-size: clamp(48px, 8vw, 92px);
  font-weight: 300;
  letter-spacing: -0.025em;
  line-height: 0.95;
  color: var(--text);
}

/* Subtitle â€” same recipe as #loading .ld-sub. Mixed case (per
   user direction "ä¸è¦ç”¨çº¯å¤§å†™"), small mono, wide tracking,
   muted colour. */
.cinematic-flourish .cf-sub {
  margin-top: 18px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.18em;
  color: var(--text-mid);
}

/* Hairline rule â€” same width / colour / spacing as #loading .ld-rule. */
.cinematic-flourish .cf-rule {
  width: 56px;
  height: 1px;
  background: var(--hair-strong);
  margin: 32px auto 28px;
}

/* Credit â€” tiny mono caption listing the tool chain. Mixed case,
   sits below the rule as the loading splash's .ld-desc-fine does. */
.cinematic-flourish .cf-credit {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 400;
  color: var(--text-dim);
  letter-spacing: 0.04em;
  line-height: 1.7;
  max-width: 480px;
  margin-left: auto;
  margin-right: auto;
}
@media (max-width: 520px) {
  .cinematic-flourish .cf-title  { font-size: clamp(40px, 12vw, 64px); }
  .cinematic-flourish .cf-sub    { font-size: 10px; letter-spacing: 0.16em; }
  .cinematic-flourish .cf-credit { font-size: 10px; }
}

/* (Loading-splash particle canvas + its z-index pinning removed â€”
   see commit history. Splash now relies on the static editorial
   layout defined in index.html with no overlay canvas.) */

/* Corner annotations */
#loading .ld-corner {
  position: absolute;
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-mid);
  display: flex;
  gap: 8px;
  align-items: baseline;
  white-space: nowrap;
}
#loading .ld-tl { top: 32px; left: 44px; }
#loading .ld-tr { top: 32px; right: 44px; }
#loading .ld-bl { bottom: 84px; left: 44px; }
#loading .ld-br { bottom: 84px; right: 44px; }
#loading .ld-k {
  color: var(--text-faint);
  padding-right: 6px;
  border-right: 1px solid var(--hair-strong);
}
#loading .ld-v { color: var(--text); }

/* Center hero stack */
#loading .ld-stage {
  grid-row: 2;
  align-self: center;
  justify-self: center;
  text-align: center;
  max-width: 720px;
  padding: 0 24px;
}
#loading .ld-title {
  margin: 0;
  font-family: var(--font-ui);
  font-size: clamp(56px, 9vw, 104px);
  font-weight: 300;
  letter-spacing: -0.025em;
  line-height: 0.95;
  color: var(--text);
}
#loading .ld-sub {
  margin-top: 18px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.34em;
  text-transform: uppercase;
  color: var(--text-mid);
}
#loading .ld-rule {
  width: 56px;
  height: 1px;
  background: var(--hair-strong);
  margin: 38px auto 32px;
}
#loading .ld-desc {
  margin: 0 auto;
  font-family: var(--font-ui);
  font-size: 19px;
  font-weight: 300;
  line-height: 1.45;
  letter-spacing: 0.005em;
  color: var(--text);
  max-width: 580px;
}
#loading .ld-desc-fine {
  margin-top: 18px;
  font-size: 13px;
  font-weight: 400;
  color: var(--text-mid);
  letter-spacing: 0.01em;
  line-height: 1.7;
  max-width: 560px;
}
#loading .ld-em { color: var(--text); font-weight: 400; }
#loading .ld-pillars {
  margin-top: 44px;
  display: flex;
  justify-content: center;
  gap: 36px;
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.20em;
  text-transform: uppercase;
  color: var(--text);
}
#loading .ld-pillar {
  display: inline-flex;
  align-items: baseline;
  gap: 8px;
  /* Default state: dim. As the download progresses past 30/60/95%, the
   * setLoadProgress helper in main.js stamps p1/p2/p3 on #loading,
   * which lights each pillar in turn. The transition is gentle so the
   * progression reads as a slow editorial fade-up, not a strobe. */
  opacity: 0.32;
  transition: opacity 0.55s cubic-bezier(0.2, 0.7, 0.2, 1.0);
}
#loading .ld-pillar-n {
  font-size: 9px;
  color: var(--text-dim);
  letter-spacing: 0.10em;
  transition: color 0.55s ease;
}
/* Sequential reveal — each progress class lights the next pillar. The
 * `:nth-child(N)` selectors target the pillar elements directly so
 * they survive a future reorder of the .ld-pillars wrapper. */
#loading.p1 .ld-pillar:nth-child(1),
#loading.p2 .ld-pillar:nth-child(-n+2),
#loading.p3 .ld-pillar { opacity: 1; }
#loading.p3 .ld-pillar-n { color: var(--text-mid); }

/* Bottom progress bar + status text */
#loading .ld-foot {
  grid-row: 3;
  display: flex;
  flex-direction: column;
  gap: 14px;
  align-items: stretch;
  max-width: 100%;
}
#loading .ld-bar {
  position: relative;
  width: 100%;
  height: 1px;
  background: rgba(255, 255, 255, 0.06);
  overflow: hidden;
}
#loading .ld-fill {
  position: absolute;
  top: 0;
  left: 0;
  width: 24%;
  height: 100%;
  background: rgba(255, 255, 255, 0.85);
  animation: ld-bar-indef 1.6s cubic-bezier(.42,.0,.58,1.0) infinite;
}
@keyframes ld-bar-indef {
  0%   { transform: translateX(-110%); }
  100% { transform: translateX(540%); }
}
/* Determinate state: real % fill driven inline by JS (setLoadProgress in
   main.js). We need to kill the indeterminate keyframe slide + the
   translateX it leaves behind so it stops fighting the live width. The
   width transition smooths chunk-by-chunk updates into a gliding bar. */
#loading .ld-fill.determinate {
  animation: none;
  transform: none;
  transition: width 0.18s ease-out;
}
#loading .ld-status {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-mid);
  text-align: center;
  min-height: 14px;
}

/* Mobile: tighten margins so corner labels still fit */
@media (max-width: 640px) {
  #loading { padding: 32px 24px 24px; }
  #loading .ld-tl, #loading .ld-bl { left: 24px; }
  #loading .ld-tr, #loading .ld-br { right: 24px; }
  #loading .ld-tl, #loading .ld-tr { top: 24px; }
  #loading .ld-bl, #loading .ld-br { bottom: 72px; }
  #loading .ld-corner { font-size: 8.5px; letter-spacing: 0.14em; }
  #loading .ld-pillars { gap: 22px; }
}

/* Narrow phones (SE / 13 Mini class, â‰¤ 480 px wide) â€” the corner
   annotations' combined horizontal width exceeds the available viewport
   even with the tighter padding above:
       bottom-left  "RUNTIME Â· SPARK Â· THREE.JS Â· WEBGL 2"  â‰ˆ 210 px
       bottom-right "SCENE Â· SPLATGARDEN"                   â‰ˆ 110 px
       sum 320 > (320 - 48 padding) on iPhone SE â†’ 48 px horizontal overlap.
   The corners are editorial chrome â€” the central title + desc + pillars
   + progress bar already carry the brand identity during the short
   load window. Hide all four on narrow phones to remove the layout
   fragility entirely; they reappear on > 480 px viewports. */
@media (max-width: 480px) {
  #loading .ld-corner { display: none; }
}

/* When the Tech Breakdown drawer is open, the SplatGarden Studio panel
 * sits at the same right-edge column and overlaps the drawer content.
 * Translate it off-screen so the drawer reads cleanly, with a soft slide
 * matching the drawer's own 0.22s entrance. pointer-events drops to
 * none so a stray click into the hidden region doesn't poke a control
 * the user can't see. The Studio slides back when the drawer closes. */
body.tech-spec-open .lil-gui.root {
  transform: translateX(calc(100% + 36px)) !important;
  opacity: 0 !important;
  pointer-events: none !important;
  transition: transform 0.22s ease, opacity 0.22s ease !important;
}
/* Same treatment for the left-side scene panel sidebar so the drawer
 * gets the full canvas width to itself when expanded. */
body.tech-spec-open #scene-panel {
  transform: translateX(calc(-100% - 36px));
  opacity: 0;
  pointer-events: none;
  transition: transform 0.22s ease, opacity 0.22s ease;
}

/* ============ lil-gui monochrome glass override ============ */
.lil-gui {
  --background-color:        rgba(18, 18, 20, 0.55) !important;
  --title-background-color:  rgba(255, 255, 255, 0.04) !important;
  --title-text-color:        var(--text) !important;
  --widget-color:            rgba(255, 255, 255, 0.06) !important;
  --hover-color:             rgba(255, 255, 255, 0.10) !important;
  --focus-color:             rgba(255, 255, 255, 0.92) !important;
  --string-color:            rgba(255, 255, 255, 0.92) !important;
  --number-color:            rgba(255, 255, 255, 0.92) !important;
  --text-color:              rgba(255, 255, 255, 0.92) !important;
  --font-family:             var(--font-ui) !important;
  --font-family-mono:        var(--font-mono) !important;
  --font-size:               11px !important;
  --input-font-size:         11px !important;
  --folder-indent:           8px !important;
  --widget-padding:          5px !important;
  --widget-border-radius:    4px !important;
  --width:                   280px !important;
  border: 1px solid var(--hair) !important;
  border-radius: var(--radius) !important;
  backdrop-filter: blur(28px) saturate(140%) !important;
  -webkit-backdrop-filter: blur(28px) saturate(140%) !important;
  box-shadow: var(--shadow-soft) !important;
  font-feature-settings: "ss01", "cv11" !important;
  overflow: hidden !important;
  top: 18px !important;
  right: 18px !important;
}
.lil-gui.root > .title {
  background: rgba(255, 255, 255, 0.04) !important;
  color: var(--text) !important;
  font-weight: 500 !important;
  letter-spacing: 0.16em !important;     /* restored â€” the borderless 26-px icons free the horizontal budget */
  text-transform: uppercase !important;
  font-size: 10px !important;             /* restored â€” no longer fighting for space with bordered pills */
  padding: 10px 8px 10px 14px !important; /* tighter right padding now that the icons end the row */
  border-bottom: 1px solid var(--hair) !important;
  /* Title row hosts injected Replay + Reset icon-buttons. Flex row +
     nowrap + min-width: 0 lets the wordmark take only what it needs while
     the borderless icons sit cleanly on the right. */
  display: flex !important;
  align-items: center !important;
  gap: 6px !important;
  white-space: nowrap !important;
  min-width: 0 !important;
}

/* Replay / Reset icon buttons in the lil-gui title row.
   PM redesign: previously these were rounded pills with grey borders, which
   read as bolted-on next to the borderless chevron `v` on the left of the
   title â€” three sibling header controls that didn't share a visual language.
   Now they match the chevron vocabulary exactly: borderless, transparent
   background, identical 26Ã—26 hit-target, identical hover treatment. The
   header reads as one cohesive icon strip (chevron Â· title Â· play Â· reset)
   instead of "title + 2 pill ornaments". The text labels are hidden
   permanently (tooltips carry the meaning) since the lil-gui panel default
   width can't host them without crowding the wordmark. */
.gui-reset-btn {
  margin-left: 0 !important;
  display: inline-flex !important;
  align-items: center !important;
  justify-content: center !important;
  width: 26px !important;
  height: 26px !important;
  padding: 0 !important;
  font: inherit !important;
  color: var(--text-mid) !important;
  background: transparent !important;
  border: 0 !important;
  border-radius: 6px !important;
  cursor: pointer !important;
  transition: background 0.15s ease, color 0.15s ease;
}
/* Replay sits immediately after the title (auto-margin pushes it to the
   right of the wordmark); Reset sits right next to Replay with a tiny gap. */
.gui-replay-btn { margin-left: auto !important; }
.gui-reset-btn + .gui-reset-btn,
.gui-replay-btn + .gui-reset-btn,
.gui-reset-btn ~ .gui-reset-btn { margin-left: 2px !important; }
.gui-reset-btn:hover {
  color: var(--text) !important;
  background: rgba(255, 255, 255, 0.06) !important;
}
.gui-reset-btn:active { background: rgba(255, 255, 255, 0.10) !important; }
.gui-reset-btn span   { display: none !important; }     /* labels hidden â€” tooltips do the work */
.gui-reset-btn svg    { display: block; width: 14px; height: 14px; flex: 0 0 auto; }
/* Closed-panel state: the title becomes a self-contained pill â€” hide the
   action buttons there because clicking the closed panel should expand it,
   not trigger an action. */
.lil-gui.root.closed .gui-reset-btn { display: none !important; }
/* On phones specifically (more aggressive), hide Reset entirely and keep
   only Replay â€” the more frequently-wanted action. Reset is still
   reachable via the GUI controls if a phone user really needs it. */
body.phone .gui-reset-btn:not(.gui-replay-btn) { display: none !important; }
  /* Tighter vertical padding when the whole panel is collapsed â€”
     keeps the chevron centered visually in the pill (see .closed rules
     below). Smooth so the height collapse feels of-a-piece. */
  transition: padding 0.22s cubic-bezier(.16,1,.3,1),
              border-radius 0.22s ease,
              border-bottom-color 0.22s ease;
}
/* Collapsed state â€” the SplatGarden Studio panel shrinks to just its
   title bar. We treat the closed panel as a SELF-CONTAINED CAPSULE
   PILL rather than a flat slab:
   â€¢ drop the title's bottom-border (no children beneath it now),
   â€¢ fatten the radius so all four corners read as a true pill,
   â€¢ tighten + balance vertical padding so the chevron + title sit
     centred in the capsule with even breathing room,
   â€¢ slim the title-row to a single height so the panel doesn't
     reserve extra space for a non-existent body,
   â€¢ brighten the hover state (background + tiny scale) so the
     capsule feels tappable rather than decorative,
   â€¢ brighten the chevron a touch so the affordance reads at a
     glance against the brighter pill background,
   â€¢ lengthen letter-spacing slightly so SPLATGARDEN STUDIO breathes
     inside the smaller pill format.
   The base .lil-gui already has `overflow: hidden`, so the rounded
   corners naturally clip whatever children remain underneath. */
.lil-gui.root.closed {
  border-radius: 999px !important;
  /* Soften the closed-state shadow a touch â€” at the smaller pill
     footprint the full panel-sized shadow read as heavy. */
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.32),
              0 1px 0 rgba(255, 255, 255, 0.06) inset !important;
  transition: background 0.2s ease, transform 0.2s ease,
              box-shadow 0.2s ease, border-color 0.2s ease !important;
}
.lil-gui.root.closed > .title {
  /* Flex row + align-items: center forces the chevron ::before pseudo
     AND the title text onto the same vertical centre. The old block
     layout left the pseudo on the baseline (defaults to
     vertical-align: baseline) which sat ~2 px above the cap-line of
     the small-caps title â€” visible as "text not vertically centred
     inside the pill" in the user's screenshot. Flex sidesteps the
     baseline alignment entirely. */
  display: flex !important;
  align-items: center !important;
  gap: 6px !important;
  line-height: 1 !important;
  padding: 12px 20px !important;
  border-bottom: 1px solid transparent !important;
  border-radius: 999px !important;
  letter-spacing: 0.20em !important;
  color: rgba(255, 255, 255, 0.92) !important;
  /* Subtle inner highlight on the top edge â€” same "etched glass"
     treatment the lil-gui itself uses, scaled down for the pill. */
  background:
    linear-gradient(180deg,
      rgba(255, 255, 255, 0.06) 0%,
      rgba(255, 255, 255, 0.02) 100%) !important;
}
/* Chevron ::before â€” explicit vertical-align inside the flex row.
   line-height: 1 + align-items: center hands the glyph the same
   centre line as the title text, killing the half-pixel baseline
   drift that made the closed pill read as "text not centred". */
.lil-gui.root.closed > .title::before {
  display: inline-flex !important;
  align-items: center !important;
  line-height: 1 !important;
  vertical-align: middle !important;
}
/* Hover â€” lift the capsule with a brighter surface + tiny scale.
   Tactile cue that the pill is a tappable affordance, not chrome. */
.lil-gui.root.closed:hover {
  background: rgba(28, 30, 36, 0.62) !important;
  border-color: rgba(255, 255, 255, 0.22) !important;
  transform: translateY(-1px);
  box-shadow: 0 12px 26px rgba(0, 0, 0, 0.40),
              0 1px 0 rgba(255, 255, 255, 0.10) inset !important;
}
.lil-gui.root.closed:hover > .title {
  color: #fff !important;
  background:
    linear-gradient(180deg,
      rgba(255, 255, 255, 0.10) 0%,
      rgba(255, 255, 255, 0.04) 100%) !important;
}
/* Chevron â€” lil-gui draws "â–¾" as a ::before pseudo on .title that
   rotates -90Â° in the closed state. Pop it from a faint grey to a
   confident white so the open affordance reads first. */
.lil-gui.root.closed > .title::before {
  opacity: 0.95 !important;
  color: #fff !important;
}
.lil-gui .title {
  font-weight: 500 !important;
  letter-spacing: 0.04em !important;
}
.lil-gui .controller {
  border-bottom: 1px solid transparent !important;
}
.lil-gui .controller.function .name {
  color: var(--text-mid) !important;
  font-weight: 500 !important;
  letter-spacing: 0.02em !important;
}
.lil-gui .controller .name {
  color: var(--text-mid) !important;
  font-weight: 400 !important;
  letter-spacing: 0.01em !important;
}
.lil-gui .controller .name .ctrl-icon {
  display: inline-block;
  width: 18px;
  height: 12px;
  margin-right: 6px;
  vertical-align: -2px;
  color: var(--text-mid);
  opacity: 0.85;
}
.lil-gui .controller .name .usd-spec-wrap {
  position: relative;
  display: inline-block;
  margin-left: 8px;
  vertical-align: 1px;
}
.lil-gui .controller .name .usd-spec {
  display: inline-block;
  padding: 1px 5px;
  font-family: var(--font-mono, monospace);
  font-size: 9px;
  letter-spacing: 0.04em;
  color: rgba(255, 255, 255, 0.6);
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 2px;
  white-space: nowrap;
  cursor: help;
}
/* USD tooltip â€” portalled to <body> so position:fixed lands relative to the
   viewport (lil-gui's panel transform would otherwise break it). Visibility
   toggled by a .show class set in JS on mouseenter/leave. */
.usd-tooltip {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9999;
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 10px;
  row-gap: 2px;
  padding: 8px 10px;
  background: rgba(8, 12, 18, 0.86);
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 4px;
  color: #d8dee6;
  font-family: var(--font-mono, monospace);
  font-size: 11px;
  letter-spacing: 0.04em;
  text-transform: none;
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.12s, visibility 0.12s;
  backdrop-filter: blur(4px);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
}
.usd-tooltip.show {
  opacity: 1 !important;
  visibility: visible !important;
  pointer-events: auto;
}
.usd-tooltip .k {
  color: rgba(255, 255, 255, 0.45);
  text-transform: uppercase;
  font-size: 9.5px;
}
.usd-tooltip .v {
  color: #f0f3f6;
}
.usd-tooltip .t-link {
  color: #f0f3f6;
  text-decoration: none;
  border-bottom: 1px dotted rgba(255, 255, 255, 0.35);
}
.usd-tooltip .t-link:hover {
  border-bottom-color: #fff;
}

/* =========================================================================
   Mobile adaptations â€” applied only when JS adds `body.mobile`.
   Desktop styling stays untouched.
   ========================================================================= */
body.mobile #sidebar {
  width: auto;
  left: 10px;
  right: 10px;
  max-height: 36vh;
  font-size: 12px;
}
body.mobile #sidebar header { padding: 10px 12px 8px; }
body.mobile #sidebar button {
  min-height: 36px;
  font-size: 12px;
}
body.mobile #hand-panel { display: none !important; }
/* Viewport Tuner + Scene panel + Viewpoints sidebar â€” all dense floating
   panels that are awkward on phone/tablet (no K/keyboard shortcuts,
   narrow viewport, keyboard-hint footer is pointless on touch).
   The numbered hotspot dots in 3D remain tappable for viewpoint navigation. */
/* PHONE-only hiding (iPad keeps these â€” wider screen accommodates them).
   Was `body.touch` originally; narrowed so tablets get the full desktop
   UI per project direction. */
body.mobile #viewpoint-tuner,
body.mobile #scene-panel,
body.mobile #sidebar { display: none !important; }
/* Museum-style hover cards â€” phone screens are too narrow to host the
   full asset card / USD annotation overlay without obstructing the
   splat. Tablets show them as desktop does. */
body.mobile #asset-hover-card,
body.mobile #usd-annotations { display: none !important; }
body.mobile .lil-gui.root {
  font-size: 12px;
  width: min(280px, 92vw);
}
body.mobile .lil-gui .title { padding: 10px 12px; }
body.mobile .lil-gui .controller {
  min-height: 32px;
}
body.mobile .lil-gui input[type="checkbox"] {
  width: 18px;
  height: 18px;
}
body.mobile #status { font-size: 10px; }
body.mobile .cam-timeline { width: min(280px, 92vw); }
/* Tap-to-close all popovers when the user taps outside the trigger */
body.mobile .usd-tooltip,
body.mobile .subform-tip { transition: opacity 0.06s, visibility 0.06s; }

/* HDRI drag-drop overlay */
.hdri-drop {
  position: fixed;
  inset: 0;
  z-index: 200;
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(8px);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.hdri-drop .hdri-card {
  width: 380px;
  padding: 24px 28px;
  background: rgba(8, 12, 18, 0.92);
  border: 1.5px dashed rgba(255, 255, 255, 0.32);
  border-radius: 8px;
  color: #d8dee6;
  font-family: var(--font-mono, monospace);
  letter-spacing: 0.04em;
  text-align: center;
  transition: border-color 0.15s, transform 0.15s;
  pointer-events: none;
}
.hdri-drop.over .hdri-card {
  border-color: rgba(255, 255, 255, 0.65);
  transform: scale(1.02);
}
.hdri-drop .hdri-title {
  font-size: 13px;
  color: #f0f3f6;
  margin-bottom: 6px;
}
.hdri-drop .hdri-hint {
  font-size: 10px;
  color: rgba(255, 255, 255, 0.45);
  text-transform: uppercase;
  margin-bottom: 10px;
}
.hdri-drop .hdri-status {
  font-size: 10.5px;
  color: #ffd086;
  min-height: 14px;
}

/* Camera-move timeline / frame readout */
.cam-timeline {
  position: fixed;
  bottom: 56px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 30;
  display: flex;
  flex-direction: column;
  gap: 6px;
  width: 320px;
  padding: 8px 12px;
  background: rgba(8, 12, 18, 0.86);
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 4px;
  color: #d8dee6;
  font-family: var(--font-mono, monospace);
  font-size: 11px;
  letter-spacing: 0.04em;
  backdrop-filter: blur(4px);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
  pointer-events: none;
}
.cam-timeline .ct-row {
  display: flex;
  align-items: center;
  gap: 12px;
  justify-content: space-between;
  flex-wrap: nowrap;
}
.cam-timeline .ct-label,
.cam-timeline .ct-time,
.cam-timeline .ct-frame {
  /* Each cell stays on one line â€” in phone landscape the timeline
     gets squeezed enough that the default wrap broke "CAMERA MOVE"
     into two lines and split "14.04s / 16.58s" across visual rows.
     nowrap forces single-line rendering and gives the flexbox a
     fair shrink target. */
  white-space: nowrap;
}
.cam-timeline .ct-label {
  color: rgba(255, 255, 255, 0.45);
  text-transform: uppercase;
  font-size: 9.5px;
  flex-shrink: 0;
}
.cam-timeline .ct-time,
.cam-timeline .ct-frame {
  color: #f0f3f6;
  font-variant-numeric: tabular-nums;
  flex-shrink: 0;
}
/* Scrub bar â€” the whole .cam-timeline carries pointer-events: none so
   the user can keep orbiting the scene during playback, but the
   progress bar itself opts back in so it accepts click + drag and
   becomes a seekable timeline. Hit area is the .ct-track (12 px
   tall transparent gutter); the visible groove is the thinner inner
   line. Same idiom YouTube uses â€” the bar is small visually but
   easy to grab. */
.cam-timeline .ct-bar {
  position: relative;
  height: 14px;
  display: flex;
  align-items: center;
  cursor: pointer;
  pointer-events: auto;
  touch-action: none;          /* drag scrubs, never page-pans */
  /* No background here â€” the visible groove is the inner .ct-track */
  overflow: visible;
}
.cam-timeline .ct-bar::before {
  /* The visible groove â€” drawn as a pseudo so the .ct-bar itself can
     stay the big-hit-area wrapper. Sits horizontally centred. */
  content: "";
  position: absolute;
  left: 0; right: 0;
  height: 3px;
  background: rgba(255, 255, 255, 0.14);
  border-radius: 2px;
  transition: height 0.15s ease, background 0.15s ease;
}
.cam-timeline .ct-bar:hover::before,
.cam-timeline.scrubbing .ct-bar::before {
  height: 5px;
  background: rgba(255, 255, 255, 0.22);
}
.cam-timeline .ct-fill {
  position: absolute;
  left: 0; top: 50%;
  transform: translateY(-50%);
  height: 3px;
  width: 0%;
  background: rgba(255, 255, 255, 0.78);
  border-radius: 2px;
  transition: width 0.06s linear, height 0.15s ease, background 0.15s ease;
  pointer-events: none;
}
.cam-timeline .ct-bar:hover .ct-fill,
.cam-timeline.scrubbing .ct-fill {
  height: 5px;
  background: #fff;
}
/* Thumb knob â€” hidden by default, appears on hover or during scrub.
   Sits at the right edge of .ct-fill so its centre tracks the play
   head. Sized for both pointer and touch (12 px visual / 14 px hit
   via the parent .ct-bar's vertical gutter). */
.cam-timeline .ct-fill::after {
  content: "";
  position: absolute;
  right: -6px;
  top: 50%;
  transform: translate(0, -50%) scale(0);
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45),
              0 0 0 3px rgba(255, 255, 255, 0.10);
  transition: transform 0.18s cubic-bezier(.18, 1, .3, 1);
  pointer-events: none;
}
.cam-timeline .ct-bar:hover .ct-fill::after,
.cam-timeline.scrubbing .ct-fill::after {
  transform: translate(0, -50%) scale(1);
}

/* Hover tooltip for training-camera frustums */
.frustum-tip {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 50;
  pointer-events: none;
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 10px;
  row-gap: 2px;
  padding: 8px 10px;
  background: rgba(8, 12, 18, 0.86);
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 4px;
  color: #d8dee6;
  font-family: var(--font-mono, monospace);
  font-size: 11px;
  letter-spacing: 0.04em;
  backdrop-filter: blur(4px);
  white-space: nowrap;
}
.frustum-tip .k {
  color: rgba(255, 255, 255, 0.45);
  text-transform: uppercase;
  font-size: 9.5px;
}
.frustum-tip .v {
  color: #f0f3f6;
}
.lil-gui input[type="text"],
.lil-gui input[type="number"] {
  background: transparent !important;
  color: var(--text) !important;
  font-family: var(--font-mono) !important;
}
.lil-gui input[type="color"] {
  border-radius: 4px !important;
}
/* lil-gui native checkbox accent â€” harmonised with the iOS-style
   toggle switches in 3DGS / USD so the panel's "active" cue uses
   the same off-white across every control type (checkbox / toggle /
   active pill / slider fill). Cross-control coherence: when you
   look at the panel, you instantly know what's "on" without
   re-reading each control's specific affordance. */
.lil-gui input[type="checkbox"] {
  accent-color: rgba(255, 255, 255, 0.92) !important;
}
.lil-gui .slider {
  background: rgba(255, 255, 255, 0.05) !important;
  border-radius: 2px !important;
}
.lil-gui .slider .fill {
  background: rgba(255, 255, 255, 0.40) !important;
}
.lil-gui .controller button,
.lil-gui .controller.function .widget {
  background: rgba(255, 255, 255, 0.04) !important;
  border: 1px solid var(--hair) !important;
  border-radius: var(--radius-sm) !important;
  color: var(--text) !important;
  /* Subtle spring lift on hover â€” translate-y + shadow keeps the
     interaction premium without being bouncy. Easing curve is
     cubic-bezier(.16,1,.3,1) â€” "ease-out-quint-ish" â€” so the lift
     feels crisp on enter and settles on exit. */
  transition: background 0.18s cubic-bezier(.16,1,.3,1),
              border-color 0.18s cubic-bezier(.16,1,.3,1),
              transform   0.22s cubic-bezier(.16,1,.3,1),
              box-shadow  0.22s cubic-bezier(.16,1,.3,1) !important;
}
.lil-gui .controller button:hover,
.lil-gui .controller.function .widget:hover {
  background: rgba(255, 255, 255, 0.10) !important;
  border-color: rgba(255, 255, 255, 0.22) !important;
  transform: translateY(-1px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.28);
}
.lil-gui .controller button:active,
.lil-gui .controller.function .widget:active {
  transform: translateY(0);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  transition-duration: 0.08s !important;
}

/* ============================================================
   Viewport Tuner â€” press K to open. Top-right of the viewport,
   under lil-gui. Shows live camera pos/target and lets you commit
   the current pose to any seeded viewpoint slot.
   ============================================================ */
#viewpoint-tuner {
  position: absolute;
  top: 18px;
  right: 322px;            /* sit left of lil-gui (right:18 + width:280 + gap) */
  width: 280px;
  z-index: 22;
  display: none;
  background: var(--panel);
  backdrop-filter: blur(28px) saturate(140%);
  -webkit-backdrop-filter: blur(28px) saturate(140%);
  border: 1px solid var(--hair);
  border-radius: var(--radius);
  box-shadow: var(--shadow-soft);
  color: var(--text);
  font-family: var(--font-ui);
  letter-spacing: 0.01em;
  user-select: none;
}
#viewpoint-tuner.show { display: block; }

#viewpoint-tuner .vt-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px 10px;
  border-bottom: 1px solid var(--hair);
}
#viewpoint-tuner .vt-title {
  flex: 1;
  font-family: var(--font-ui);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text);
}
#viewpoint-tuner .vt-key {
  font-family: var(--font-mono);
  background: rgba(255, 255, 255, 0.06);
  padding: 1px 6px;
  border-radius: 3px;
  font-size: 9px;
  color: var(--text-mid);
  letter-spacing: 0.10em;
}
#viewpoint-tuner .vt-close,
#viewpoint-tuner .vt-min {
  appearance: none;
  background: transparent;
  border: 1px solid var(--hair-strong);
  border-radius: 4px;
  width: 22px;
  height: 22px;
  color: var(--text-mid);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  padding: 0;
  transition: background 0.12s, color 0.12s, border-color 0.12s;
}
#viewpoint-tuner .vt-close:hover,
#viewpoint-tuner .vt-min:hover {
  background: var(--hover-strong);
  border-color: rgba(255, 255, 255, 0.22);
  color: var(--text);
}
/* Animated minimize â€” body collapses via max-height + opacity so the
 * header stays put while the contents slide out cleanly. */
#viewpoint-tuner .vt-body {
  overflow: hidden;
  max-height: 1200px;
  opacity: 1;
  transition: max-height 0.28s cubic-bezier(0.22, 1, 0.36, 1),
              opacity    0.18s ease,
              padding    0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
#viewpoint-tuner.minimized .vt-body {
  max-height: 0;
  opacity: 0;
  padding-top: 0;
  padding-bottom: 0;
}
#viewpoint-tuner.minimized .vt-head { cursor: pointer; }

#viewpoint-tuner .vt-body { padding: 12px 14px 14px; }

#viewpoint-tuner .vt-row {
  display: flex;
  align-items: baseline;
  gap: 10px;
  padding: 3px 0;
}
#viewpoint-tuner .vt-row .vt-k {
  font-family: var(--font-ui);
  font-size: 10px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-dim);
  width: 56px;
  flex-shrink: 0;
}
#viewpoint-tuner .vt-row .vt-v {
  flex: 1;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.02em;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
  border-radius: 3px;
  padding: 3px 7px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

#viewpoint-tuner .vt-action {
  display: block;
  width: 100%;
  margin-top: 12px;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--hair-strong);
  border-radius: var(--radius-sm);
  padding: 7px 10px;
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.06em;
  cursor: pointer;
  transition: background 0.12s, border-color 0.12s;
}
#viewpoint-tuner .vt-action:hover {
  background: var(--hover-strong);
  border-color: rgba(255, 255, 255, 0.22);
}

#viewpoint-tuner .vt-sec-title {
  font-family: var(--font-ui);
  font-size: 9px;
  font-weight: 500;
  letter-spacing: 0.20em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin: 16px 0 8px;
}

#viewpoint-tuner .vt-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 5px;
}
#viewpoint-tuner .vt-save-btn {
  background: transparent;
  border: 1px solid var(--hair-strong);
  border-radius: var(--radius-sm);
  padding: 6px 8px;
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 11px;
  letter-spacing: 0.02em;
  cursor: pointer;
  transition: background 0.12s, border-color 0.12s;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
#viewpoint-tuner .vt-save-btn:hover {
  background: var(--hover-strong);
  border-color: rgba(255, 255, 255, 0.22);
}
#viewpoint-tuner .vt-save-btn.flash {
  background: rgba(255, 255, 255, 0.16);
  border-color: rgba(255, 255, 255, 0.45);
}

#viewpoint-tuner .vt-hint {
  margin-top: 10px;
  font-family: var(--font-ui);
  font-size: 10px;
  line-height: 1.45;
  color: var(--text-dim);
}

/* ============================================================
   Quick Guide â€” keyboard / mouse shortcut card
   - Floats bottom-centre on first entry, auto-hides after a few seconds.
   - Press H to summon back, Esc / Ã— to dismiss, hover to keep open.
   ============================================================ */
#key-hints {
  position: absolute;
  bottom: 100px;
  left: 50%;
  transform: translateX(-50%) translateY(14px);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  z-index: 25;
  width: min(560px, calc(100vw - 60px));
  /* (Original see-through panel â€” user kept this; the readability
     concern turned out to be LAYOUT / typography, not backdrop.) */
  background: var(--panel-strong);
  backdrop-filter: blur(28px) saturate(140%);
  -webkit-backdrop-filter: blur(28px) saturate(140%);
  border: 1px solid var(--hair);
  border-radius: var(--radius);
  box-shadow: var(--shadow-soft);
  color: var(--text);
  font-family: var(--font-ui);
  letter-spacing: 0.01em;
  transition: opacity 0.32s ease, transform 0.32s ease, visibility 0s linear 0.32s;
  user-select: none;
}
#key-hints.show {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
  pointer-events: auto;
  transition: opacity 0.32s ease, transform 0.32s ease, visibility 0s linear 0s;
}
/* Staggered section reveal â€” same micro-choreography as `body.ui-ready`
   on the main UI surfaces. Each `.kh-sec` fades + slides up 6 px with
   80 ms between sections, so the Quick Guide reads as a sequence of
   "tips" landing one after another rather than a single block. The
   .show toggle fires the keyframe; reapply on hide â†’ re-show via the
   `both` fill-mode + opacity baseline below. */
#key-hints .kh-sec {
  opacity: 0;
  transform: translateY(6px);
}
#key-hints.show .kh-sec {
  animation: kh-sec-in 0.42s cubic-bezier(.16, 1, .3, 1) both;
}
#key-hints.show .kh-sec:nth-child(1) { animation-delay:   0ms; }
#key-hints.show .kh-sec:nth-child(2) { animation-delay:  80ms; }
#key-hints.show .kh-sec:nth-child(3) { animation-delay: 160ms; }
#key-hints.show .kh-sec:nth-child(4) { animation-delay: 240ms; }
@keyframes kh-sec-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0);   }
}

#key-hints .kh-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px 10px;
  border-bottom: 1px solid var(--hair);
}
#key-hints .kh-title {
  flex: 1;
  font-family: var(--font-ui);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--text);
}
#key-hints .kh-key {
  font-family: var(--font-mono);
  background: rgba(255, 255, 255, 0.06);
  padding: 1px 6px;
  border-radius: 3px;
  font-size: 9px;
  color: var(--text-mid);
  letter-spacing: 0.10em;
}
#key-hints .kh-close {
  appearance: none;
  background: transparent;
  border: 1px solid var(--hair-strong);
  border-radius: 4px;
  width: 22px;
  height: 22px;
  color: var(--text-mid);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  padding: 0;
  transition: background 0.12s, color 0.12s, border-color 0.12s;
}
#key-hints .kh-close:hover {
  background: var(--hover-strong);
  border-color: rgba(255, 255, 255, 0.22);
  color: var(--text);
}

/* Quick Guide body â€” redesigned for readability per PM review.
   Old layout used a 2-column section grid with a fixed 110-px key column
   inside each row. Problems flagged by the user:
   â€¢ zigzag scan path (sections side-by-side at different heights)
   â€¢ fixed key column wasted space for short keys ([T]) and overflowed long
     chords ([W][A][S][D])
   â€¢ section titles indistinguishable from row labels
   New layout:
   â€¢ single column by default (cheat-sheet reading flow, top-to-bottom)
   â€¢ opt-in 2-column at wide widths via @media (keeps the card compact on
     desktops with room to spare, but never sacrifices readability)
   â€¢ per-row grid auto-sizes the key column to its content, gap is fixed
   â€¢ section titles get more breathing room above + stronger contrast vs.
     row labels so the two tiers don't blur on a quick scan */
#key-hints .kh-body {
  padding: 16px 20px 18px;
  display: flex;
  flex-direction: column;
  gap: 18px;
}
@media (min-width: 700px) {
  /* On wider hosts, fall back to 2-column layout to keep the card from
     becoming a very tall list. column-gap is generous so the two columns
     read as distinct vertical lanes, not a continuous flow. */
  #key-hints .kh-body {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: 32px;
    row-gap: 20px;
  }
}
/* Section title â€” bigger, more letter-spacing, hairline accent on the
   left. The accent gives the eye an anchor at the section's x-position
   so the row labels visually align beneath it. */
#key-hints .kh-sec-title {
  position: relative;
  padding-left: 10px;
  font-family: var(--font-ui);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--text);
  margin-bottom: 10px;
}
#key-hints .kh-sec-title::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 3px;
  height: 11px;
  background: rgba(255, 255, 255, 0.40);
  border-radius: 2px;
}
#key-hints .kh-list {
  list-style: none;
  margin: 0;
  padding: 0 0 0 10px;     /* indent rows to align with section title's accent edge */
  display: flex;
  flex-direction: column;
  gap: 7px;
}
/* Row pairing â€” auto-sized key column instead of a fixed 110 px, and a
   tighter 12 px gap. Result: keys and labels read as one chord per row
   with no extra horizontal whitespace to bridge. */
#key-hints .kh-row {
  display: grid;
  grid-template-columns: minmax(72px, auto) 1fr;
  align-items: center;
  gap: 12px;
  font-size: 11.5px;
}
#key-hints .kh-row .kh-k {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  flex-wrap: wrap;
  font-family: var(--font-mono);
}
#key-hints .kh-row .kh-v {
  color: var(--text-mid);
  font-size: 11.5px;
  letter-spacing: 0.005em;
}
#key-hints .kh-mouse {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-mid);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
  padding: 2px 7px;
  border-radius: 3px;
  white-space: nowrap;
}
#key-hints .kh-row kbd {
  display: inline-block;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--hair-strong);
  border-radius: 4px;
  padding: 2px 6px;
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text);
  line-height: 1.2;
  min-width: 14px;
  text-align: center;
}

@media (max-width: 640px) {
  #key-hints {
    bottom: 80px;
    width: calc(100vw - 32px);
  }
  #key-hints .kh-body {
    grid-template-columns: 1fr;
    gap: 14px;
  }
}

/* ============================================================
   Asset hotspots + hover info card
   - A small floating dot sits at each TECH_SPECS asset's worldPos
   - Hover the dot â†’ poster-style overlay card pops in the upper-centre
     of the viewport; click locks it open (pinned state, close Ã— visible)
   ============================================================ */
.asset-hotspot {
  position: absolute;
  left: 0; top: 0;
  width: 22px; height: 22px;
  margin-left: -11px; margin-top: -11px;
  z-index: 12;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  pointer-events: auto;
  will-change: transform;
  /* Button reset â€” element is now a <button> for VoiceOver / screen
     reader semantics, so we strip the default browser button chrome
     and keep the visual identical to the previous <div>. */
  background: transparent;
  border: 0;
  padding: 0;
  color: inherit;
  font: inherit;
  outline: none;
}
/* Invisible 44 Ã— 44 hit halo on every device (was body.touch-only).
   Pulls effective tap target up to Apple HIG minimum without
   resizing the visible dot. Pointer events bubble to the button
   owner so the existing click / pointer handlers fire as before. */
.asset-hotspot::before {
  content: "";
  position: absolute;
  inset: -11px;
  border-radius: 50%;
}
/* Tap reactivity â€” when .asset-hotspot--firing is added (by
   asset-hover.js on click / tap / phone tap), the visible dot
   does a quick pop-scale and a separate burst ring expands
   outward. Together with the camera fly-to, the click feels
   like a "reveal" instead of just a transit. The class is
   removed ~480 ms later so a subsequent tap can re-fire. */
.asset-hotspot .ahot-burst {
  position: absolute;
  inset: -3px;
  border-radius: 50%;
  border: 2px solid rgba(255, 255, 255, 0.92);
  box-shadow: 0 0 18px rgba(255, 255, 255, 0.35);
  opacity: 0;
  pointer-events: none;
  transform: scale(1);
}
.asset-hotspot--firing .ahot-burst {
  animation: ahotBurst 0.45s cubic-bezier(.2, .7, .3, 1) forwards;
}
.asset-hotspot--firing .ahot-dot {
  animation: ahotPop 0.35s cubic-bezier(.2, .7, .3, 1);
}
@keyframes ahotBurst {
  0%   { opacity: 0.95; transform: scale(1);   border-width: 2px; }
  60%  { opacity: 0.45; transform: scale(3.2); border-width: 1px; }
  100% { opacity: 0;    transform: scale(5);   border-width: 0.5px; }
}
@keyframes ahotPop {
  0%   { transform: scale(1); }
  35%  { transform: scale(1.55); }
  100% { transform: scale(1); }
}
.asset-hotspot .ahot-dot {
  width: 10px; height: 10px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.98);
  box-shadow:
    0 0 14px rgba(255, 255, 255, 0.65),
    0 0 0 1px rgba(0, 0, 0, 0.55);
  transition: transform 0.15s cubic-bezier(.2,.7,.3,1);
}
.asset-hotspot .ahot-ring {
  position: absolute;
  inset: -2px;
  border-radius: 50%;
  border: 1.5px solid rgba(255, 255, 255, 0.85);
  animation: ahotPulse 1.8s ease-in-out infinite;
}
.asset-hotspot .ahot-label {
  position: absolute;
  left: 26px;
  top: 50%;
  transform: translateY(-50%);
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.10em;
  /* Honour the asset's authored casing (e.g. "Gazebo", "Daffodil")
     instead of forcing ALL CAPS â€” the names read more naturally and
     match how the assets are referenced in the rest of the UI. */
  color: var(--text);
  background: rgba(8, 12, 18, 0.82);
  border: 1px solid var(--hair-strong);
  border-radius: 4px;
  padding: 4px 9px;
  pointer-events: none;
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
  transition: background 0.15s ease, border-color 0.15s ease;
}
.asset-hotspot:hover .ahot-dot { transform: scale(1.3); }
.asset-hotspot:hover .ahot-label {
  background: rgba(8, 12, 18, 0.92);
  border-color: rgba(255, 255, 255, 0.40);
}
@keyframes ahotPulse {
  0%, 100% { opacity: 0.30; transform: scale(1.00); }
  50%      { opacity: 1.00; transform: scale(1.85); }
}

/* ---------- Card ---------- */
#asset-hover-card {
  /* No default centering â€” _anchorCardToDot() in asset-hover.js sets
   * left/top so the card sits next to the dot the user is hovering
   * (right or left), eliminating the centre-jump flicker. z-index sits
   * above all the regular page UI (lil-gui 1001, onboarding pointers
   * 1050, USD annotation 1080, Credits 1100); only the Pipeline drawer
   * (5000) and mobile-nav drawer (1500) still win above. */
  position: fixed;
  top: 36px;
  left: 36px;
  width: min(600px, 72vw);
  max-height: calc(100vh - 160px);
  z-index: 1200;
  background: var(--panel-strong);
  backdrop-filter: blur(28px) saturate(140%);
  -webkit-backdrop-filter: blur(28px) saturate(140%);
  border: 1px solid var(--hair);
  border-radius: var(--radius);
  box-shadow: var(--shadow-soft);
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 11px;
  letter-spacing: 0.01em;
  user-select: text;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.20) transparent;
}
#asset-hover-card[hidden] { display: none; }
/* Card entrance — opacity-only fade so it doesn't fight asset-hover.js,
 * which sets `transform: none` on the card immediately after showing it
 * (the JS controls position via `left` / `top` and explicitly nukes any
 * CSS transform to avoid a centring offset). A scale animation would
 * compete with that inline transform write; an opacity-only animation
 * doesn't. Plays every time the card transitions from [hidden] to
 * visible, so reopening the card on a different asset reads as a fresh
 * fade-in rather than a snap. */
#asset-hover-card:not([hidden]) {
  animation: ah-card-enter 0.22s cubic-bezier(0.2, 0.7, 0.2, 1.0) both;
}
@keyframes ah-card-enter {
  from { opacity: 0; }
  to   { opacity: 1; }
}
#asset-hover-card::-webkit-scrollbar { width: 6px; }
#asset-hover-card::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.18);
  border-radius: 3px;
}

.ah-card .ah-close {
  /* Sticky-pinned to the top-right of the card's visible viewport, NOT
     to the top of the card's content. The card is the scroll container
     (overflow-y: auto on #asset-hover-card); a previous position:absolute
     anchored the X to the content's origin, so scrolling the card body
     moved the X up out of view — the user had to scroll back up just
     to close. position:sticky keeps the X in the visible upper-right
     corner regardless of scroll position. The float:right is what gives
     us the right-edge anchor (sticky only handles vertical adhesion);
     the negative margin-bottom keeps the X from claiming a row of
     vertical space that would push the actual content down. */
  position: sticky;
  top: 10px;
  float: right;
  margin-right: 10px;
  margin-bottom: -38px;       /* button height + small gap, so content
                                 isn't pushed down by the floated button */
  width: 28px;
  height: 28px;
  background: rgba(10, 12, 16, 0.72);  /* semi-opaque so it stays legible
                                          when scrolled content is behind it */
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px solid var(--hair-strong);
  border-radius: 4px;
  box-sizing: border-box;
  color: var(--text-mid);
  font-size: 18px;
  font-family: var(--font-ui);
  line-height: 1;
  padding: 0;
  cursor: pointer;
  /* Hidden by default — the `.pinned .ah-close` rule below flips
     display:inline-flex when the card is pinned. flex centring keeps
     the × glyph geometrically centred in the box. */
  display: none;
  align-items: center;
  justify-content: center;
  z-index: 2;                 /* above the scrolled content beneath */
  transition: background 0.12s, color 0.12s, border-color 0.12s;
}
#asset-hover-card.pinned .ah-close { display: inline-flex; }
.ah-card .ah-close:hover {
  background: var(--hover-strong);
  border-color: rgba(255, 255, 255, 0.22);
  color: var(--text);
}

/* When an asset card is PINNED (clicked open, not hover-only), dim the
   right-rail lil-gui so the card reads as the primary surface. z-index
   already puts the card at 1200 above lil-gui's 1001, but on viewports
   where the card doesn't horizontally overlap lil-gui the user still
   perceives the lil-gui as a competing UI surface. Dimming + slight
   desaturation pushes it visually back without removing the
   "navigate elsewhere" affordance entirely. Same pattern used for the
   About panel + mobile bottombar earlier. */
body:has(#asset-hover-card.pinned) .lil-gui.root {
  opacity: 0.45;
  filter: saturate(0.6);
  transition: opacity 0.22s ease, filter 0.22s ease;
}
body:has(#asset-hover-card.pinned) .lil-gui.root:hover {
  /* Restore fully on hover so the user can intentionally navigate. */
  opacity: 1;
  filter: none;
}

.ah-card .ah-head {
  padding: 16px 18px 12px;
  border-bottom: 1px solid var(--hair);
  display: flex;
  align-items: baseline;
  gap: 12px;
  cursor: grab;
  user-select: none;
  -webkit-user-select: none;
}
#asset-hover-card.dragging {
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
  transition: none;
}
#asset-hover-card.dragging .ah-head { cursor: grabbing; }
.ah-card .ah-name {
  flex: 1;
  font-family: var(--font-ui);
  font-size: 22px;
  font-weight: 500;
  letter-spacing: 0.02em;
  /* Honour the asset's authored casing (e.g. "Gazebo", "Daffodil") â€”
     the ALL CAPS treatment was visually loud and the asset names
     already carry their own intentional capitalisation. */
  color: var(--text);
}
.ah-card .ah-loc {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--text-mid);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
  padding: 3px 8px;
  border-radius: 3px;
  white-space: nowrap;
}

.ah-card .ah-section {
  /* Vertical rhythm trimmed (was 14px) â€” the section eyebrows + chip
     rows had so much breathing room that on shorter viewports two of
     them ate the screen, with no signal that more content sat below. */
  padding: 10px 18px;
  border-bottom: 1px solid var(--hair);
}
.ah-card .ah-section:last-of-type { border-bottom: none; }
.ah-card .ah-sec-title {
  font-family: var(--font-ui);
  font-size: 9px;
  font-weight: 500;
  letter-spacing: 0.20em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 6px;
}

/* Toolchain chip row */
.ah-card .ah-chain {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 5px;
}
.ah-card .ah-chip {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.04em;
  color: var(--text);
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--hair-strong);
  padding: 3px 8px;
  border-radius: 4px;
  white-space: nowrap;
}
.ah-card .ah-arrow {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text-faint);
}

/* Triptych â€” Style Reference / Original Texture / Result */
.ah-card .ah-triptych {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 10px;
}
.ah-card .ah-triptych figure {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.ah-card .ah-frame {
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-sm);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
}
.ah-card .ah-frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.ah-card .ah-ph {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-faint);
}
.ah-card figcaption {
  font-family: var(--font-ui);
  font-size: 10px;
  color: var(--text-mid);
  letter-spacing: 0.02em;
  text-align: center;
}

/* Houdini SIM video â€” autoplay loop hero embed (Gazebo + future sim items) */
:is(.ah-card, .ts-rich) .ah-sim-frame {
  position: relative;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-sm);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
  overflow: hidden;
}
:is(.ah-card, .ts-rich) .ah-sim-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  background: #000;
}
:is(.ah-card, .ts-rich) .ah-sim-ph {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  background:
    radial-gradient(120% 80% at 50% 30%, rgba(255, 200, 130, 0.10), transparent 65%),
    linear-gradient(180deg, rgba(140, 160, 180, 0.06), rgba(40, 60, 90, 0.08));
  text-align: center;
  padding: 14px;
}
:is(.ah-card, .ts-rich) .ah-sim-ph-eyebrow {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.20em;
  text-transform: uppercase;
  color: var(--text);
}
:is(.ah-card, .ts-rich) .ah-sim-ph-body {
  font-family: var(--font-ui);
  font-size: 10.5px;
  color: var(--text-mid);
  letter-spacing: 0.02em;
}
:is(.ah-card, .ts-rich) .ah-sim-ph-body code {
  font-family: var(--font-mono);
  font-size: 9.5px;
  padding: 1px 5px;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--hair);
  border-radius: 3px;
  color: var(--text);
}

/* Embedded video (Vimeo / YouTube / generic iframe) â€” 16:9 wrapper */
:is(.ah-card, .ts-rich) .ah-embed-frame {
  position: relative;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-sm);
  border: 1px solid var(--hair);
  overflow: hidden;
  background: #000;
}
:is(.ah-card, .ts-rich) .ah-embed-frame iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}

/* ============================================================
   Process cards â€” chip-labeled visual cards used for assets with a
   rich design-walkthrough story (Daisy / Real-time Ready Foliage).
   Each card has an optional chip label, one or more image rows
   (single or pair layout), and an optional bottom note.
   ============================================================ */
/* (Old .ah-process-cards container + .ah-process-card rounded-box +
   .ah-pc-chip badge styles were removed per user direction â€”
   "æˆ‘ä¸è¦è¿™æ ·çš„å¡ç‰‡åµŒå¥—æŽ’ç‰ˆï¼Œç±»ä¼¼Gazeboè¿™æ ·å¹³é“ºçš„å°±å¯ä»¥". Both styles
   now render as flat <section class="ah-section"> blocks; the chip
   label became a regular .ah-sec-title header, matching Gazebo's
   "HOUDINI 3DGS SIMULATION" treatment. Per-card typography for the
   step variant â€” eyebrow + bold title + description â€” is preserved
   below; the rounded-box wrapper is gone.) */

/* â”€â”€ Step-style process section (eyebrow + title + description) â”€â”€
   Used by Grape Hyacinth's 01/02/03 numbered breakdown. Renders the
   richer editorial header â€” small uppercase numbered eyebrow, bold
   title h3, prose description â€” then media rows with captions ABOVE.
   FLAT (no rounded-box wrapper) per user direction; sibling spacing
   handled by the .ah-section margin from style.css's section rules. */
:is(.ah-card, .ts-rich) .ah-pc-eyebrow {
  font-family: var(--font-ui);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 10px;
}
:is(.ah-card, .ts-rich) .ah-pc-title {
  font-family: var(--font-ui);
  font-size: clamp(14px, 1.5vw, 17px);
  font-weight: 600;
  line-height: 1.25;
  letter-spacing: -0.015em;
  color: var(--text);
  margin: 0 0 12px;
}
:is(.ah-card, .ts-rich) .ah-pc-desc {
  font-family: var(--font-ui);
  font-size: 12px;
  line-height: 1.65;
  color: var(--text-mid);
  margin: 0 0 18px;
}
/* Per-row sub-heading inside a processCard. Sits between rows when a
   single card needs to group multiple subform rows under their own
   smaller header (the Daffodil STYLIZATION card uses three sub-groups:
   Original / AI Stylized / Substance Painter + AI Stylized Tool). Sits
   visually between the eyebrow-title-desc block at the top and the
   image rows themselves, so the typography scale is intentionally
   between the two — slightly smaller than the card title, heavier than
   the body figcaptions. Hairline rule above each heading separates
   adjacent groups without adding box clutter. */
:is(.ah-card, .ts-rich) .ah-pc-row-heading {
  font-family: var(--font-ui);
  font-size: 13px;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -0.005em;
  color: var(--text);
  margin: 18px 0 10px;
  padding-top: 14px;
  border-top: 1px solid var(--hair);
}
/* First row heading in a card sits flush after the card title / desc,
   so suppress the rule + top padding for that specific case to avoid a
   doubled separator. */
:is(.ah-card, .ts-rich) .ah-pc-desc + .ah-pc-row-heading,
:is(.ah-card, .ts-rich) .ah-pc-title + .ah-pc-row-heading,
:is(.ah-card, .ts-rich) .ah-pc-eyebrow + .ah-pc-row-heading {
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
}
/* Caption ABOVE the image in step-style cards. Default figcaption uses
   normal-flow order (caption AFTER frame); the .ah-pc-fig-cap-above
   variant inverts via flex-direction so the caption appears above. */
:is(.ah-card, .ts-rich) .ah-pc-fig-cap-above {
  display: flex;
  flex-direction: column;
}
:is(.ah-card, .ts-rich) .ah-pc-fig-cap-above figcaption {
  order: 0;
  margin: 0 0 6px;
}
:is(.ah-card, .ts-rich) .ah-pc-fig-cap-above .ah-frame {
  order: 1;
}
/* iframe inside a step-style frame fills it cleanly. Iframes need an
   explicit aspect ratio (no intrinsic dimensions like images have) â€”
   the frame gets 16:9 so Vimeo embeds sit in their natural ratio
   without letterbox. Images in the same single-row position are
   handled separately below: natural aspect, no forced plinth. */
:is(.ah-card, .ts-rich) .ah-pc-fig .ah-frame iframe {
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}
:is(.ah-card, .ts-rich) .ah-pc-step .ah-pc-single .ah-frame:has(iframe) {
  aspect-ratio: 16 / 9;
}
:is(.ah-card, .ts-rich) .ah-pc-step .ah-pc-single .ah-frame iframe {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* Step-style single-image frames preserve natural image aspect so we
   never letterbox a hero shot. Matches the user direction "uiæ¡†ä¿ç•™
   å›¾ç‰‡æ¯”ä¾‹" â€” frame fits image, not the other way around. */
:is(.ah-card, .ts-rich) .ah-pc-step .ah-pc-single .ah-frame img {
  width: 100%;
  height: auto;
  display: block;
}

/* â”€â”€ Key Points list (bottom-of-card summary bullets) â”€â”€
   Used by Grape Hyacinth's Houdini/Unreal/Performance/Rendering wrap-up.
   Small bulleted list with bold key + colon + value, designed to read
   as a quick-reference checklist after the visual sections above. */
:is(.ah-card, .ts-rich) .ah-keypoints ul {
  list-style: none;
  margin: 0;
  padding: 0;
}
:is(.ah-card, .ts-rich) .ah-keypoints li {
  position: relative;
  padding-left: 14px;
  margin-bottom: 9px;
  font-family: var(--font-ui);
  font-size: 12px;
  line-height: 1.6;
  color: var(--text-mid);
  letter-spacing: 0.005em;
}
:is(.ah-card, .ts-rich) .ah-keypoints li::before {
  content: "•";
  position: absolute;
  left: 2px;
  top: 0;
  color: var(--text-dim);
  font-size: 14px;
  line-height: 1.4;
}
:is(.ah-card, .ts-rich) .ah-keypoints li strong {
  color: var(--text);
  font-weight: 600;
}
:is(.ah-card, .ts-rich) .ah-keypoints li:last-child {
  margin-bottom: 0;
}
:is(.ah-card, .ts-rich) .ah-pc-row {
  margin-bottom: 12px;
}
:is(.ah-card, .ts-rich) .ah-pc-row:last-child {
  margin-bottom: 0;
}
/* Compare-slider row inside a processCard. Adjustments to default
   .ts-compare styling for nested use:
   1. Override default 16:9 frame ratio with --cmp-aspect (defaults to
      1:1, set per-row from JS â€” preserves source image's natural
      aspect so square Substance textures don't crop).
   2. Bump bottom gap so adjacent compare rows breathe apart.
   3. When row has multiple compare items (--ah-pc-compare-grid class),
      lay them out side-by-side in a 2-column grid; the wipe slider
      still works inside each cell independently. */
:is(.ah-card, .ts-rich) .ah-pc-row.ah-pc-compare {
  margin-bottom: 16px;
}
:is(.ah-card, .ts-rich) .ah-pc-row.ah-pc-compare:last-child {
  margin-bottom: 0;
}
:is(.ah-card, .ts-rich) .ah-pc-row.ah-pc-compare .ts-compare {
  margin-top: 0;
}
:is(.ah-card, .ts-rich) .ah-pc-row.ah-pc-compare .cmp-frame {
  aspect-ratio: var(--cmp-aspect, 1 / 1);
}
:is(.ah-card, .ts-rich) .ah-pc-row.ah-pc-compare.ah-pc-compare-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 10px;
}
:is(.ah-card, .ts-rich) .ah-pc-cmp-cell {
  min-width: 0;
}
:is(.ah-card, .ts-rich) .ah-pc-cmp-cell .cmp-frame {
  aspect-ratio: var(--cmp-aspect, 1 / 1);
}
@media (max-width: 480px) {
  /* On phones, two square compares side-by-side become tiny â€” stack them. */
  :is(.ah-card, .ts-rich) .ah-pc-row.ah-pc-compare.ah-pc-compare-grid {
    grid-template-columns: 1fr;
  }
}
:is(.ah-card, .ts-rich) .ah-pc-fig {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
:is(.ah-card, .ts-rich) .ah-pc-fig .ah-frame {
  /* Checkerboard plinth â€” the universal DCC convention for "this image
     has alpha" (Photoshop, Substance Painter, Marmoset all use it).
     PNGs with alpha (like Daffodil's BaseColor â€” the UV-mask-cropped
     painterly diffuse) used to reveal the card's dark surface through
     their transparent pixels, which looked like a rendering glitch.
     Now the alpha is presented as DESIGN INFORMATION on a subtle
     2-tone checker. Opaque images (JPGs, videos, regular PNGs) cover
     the checker entirely so visually there's zero change for them â€”
     this only activates where alpha actually exists. Two near-black
     greys (#161616 / #1c1c1c) keep the checker quiet against the
     card's dark surface; size 16px reads as "texture preview" without
     calling attention to itself. */
  background-color: #161616;
  background-image:
    linear-gradient(45deg,  #1c1c1c 25%, transparent 25%, transparent 75%, #1c1c1c 75%),
    linear-gradient(45deg,  #1c1c1c 25%, transparent 25%, transparent 75%, #1c1c1c 75%);
  background-size: 16px 16px;
  background-position: 0 0, 8px 8px;
  border-radius: 8px;
  overflow: hidden;
  /* Subtle hairline border â€” the diagrams from Houdini / Unreal /
     Substance often have pure-black backgrounds that bleed into the
     card's own dark surface, losing frame definition. A 1px low-alpha
     border re-establishes the edge without adding visual chrome.
     Bumped from 0.06 â†’ 0.08 per the audit â€” at 0.06 the edge was
     invisible on regular (non-retina) monitors. */
  border: 1px solid rgba(255, 255, 255, 0.08);
  /* aspect-ratio: auto â€” frame fits the image's natural height, never
     letterboxes. The base .ah-card .ah-frame rule sets a 1/1 square,
     which is right for the triptych (Style/Original/Result tiles)
     where we WANT a uniform plinth â€” but wrong here. processCard
     images range from square node-graphs to wide hero plates; the
     box must follow the picture, not the other way around. Image
     itself uses `height: auto` via .ah-pc-single .ah-frame img to
     compute its natural height inside the now-flexible parent. */
  aspect-ratio: auto;
  /* Drop the flex centering inherited from .ah-frame too â€” with the
     frame matching the image's natural height there's no leftover
     space to center within, and `align-items: center` was producing
     a sub-pixel gap on some images. */
  display: block;
}
/* Iframe (Vimeo / YouTube) frames flatten the checker â€” the iframe's
   own AA edges + rounded corners revealed the checker pattern at
   pixel-level seams. Video plates always read better against solid
   black anyway. The :has() selector keeps this scoped to frames that
   actually contain a video â€” image frames keep their checker. */
:is(.ah-card, .ts-rich) .ah-pc-fig .ah-frame:has(iframe) {
  background-color: #0b0b0b;
  background-image: none;
}
/* Iframes have no intrinsic dimensions, so they need an explicit ratio
   on the parent frame. Re-apply 16:9 for any single-row frame whose
   child is an iframe (Vimeo / YouTube). vimeo-fit.js still runs and
   overrides this inline with the clip's actual ratio once the Player
   API reports back, but 16:9 is the right starting guess pre-measure. */
:is(.ah-card, .ts-rich) .ah-pc-fig .ah-frame:has(iframe) {
  aspect-ratio: 16 / 9;
}
:is(.ah-card, .ts-rich) .ah-pc-fig figcaption {
  font-family: var(--font-ui);
  font-size: 10.5px;
  letter-spacing: 0.01em;
  color: var(--text-dim);
  text-align: center;
  line-height: 1.4;
}

/* SINGLE-image rows â€” natural aspect, no constraint.
   The image is the whole story; let it breathe at its native ratio. */
:is(.ah-card, .ts-rich) .ah-pc-single .ah-frame img {
  width: 100%;
  height: auto;
  display: block;
}

/* PAIR rows â€” each frame preserves its source image's natural aspect
   ratio per user direction ("uiæ¡†ä¿ç•™å›¾ç‰‡æ¯”ä¾‹"). No fixed plinth, no
   `object-fit: cover` crop, no letterbox bars. Side-by-side images
   align at the TOP; when the two source images share aspect (e.g. a
   before/after pair rendered from the same graph at the same res) the
   heights match naturally. When they differ (a portrait viewport
   beside a landscape node-graph), each image stands at its own height
   intact â€” the user accepted that tradeoff in exchange for never
   cropping or letterboxing showcase imagery. */
:is(.ah-card, .ts-rich) .ah-pc-pair {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  align-items: start;
}
:is(.ah-card, .ts-rich) .ah-pc-pair .ah-pc-fig .ah-frame {
  /* No fixed aspect by default â€” frame fits the image */
}
:is(.ah-card, .ts-rich) .ah-pc-pair .ah-pc-fig .ah-frame img {
  width: 100%;
  height: auto;
  display: block;
  object-fit: initial;
}
/* OPT-IN equal-height mode â€” when the pair row declares an
   `aspectRatio` (e.g. "16 / 9" / "4 / 3"), both frames lock to that
   ratio so the two cells line up at exactly the same height. Image
   uses object-fit: cover to fill â€” natural-aspect preservation is
   traded for visual rhythm. Use this when the two source images have
   wildly different aspects (e.g. a portrait viewport beside a 2:1
   landscape node-graph): without it the cells end up ragged-bottom.
   The .ah-pc-fig also stretches so the (frame + caption) column
   matches its sibling â€” caption position stays consistent. */
:is(.ah-card, .ts-rich) .ah-pc-pair[style*="--pair-aspect"] {
  align-items: stretch;
}
:is(.ah-card, .ts-rich) .ah-pc-pair[style*="--pair-aspect"] .ah-pc-fig {
  height: 100%;
}
:is(.ah-card, .ts-rich) .ah-pc-pair[style*="--pair-aspect"] .ah-pc-fig .ah-frame {
  aspect-ratio: var(--pair-aspect);
}
:is(.ah-card, .ts-rich) .ah-pc-pair[style*="--pair-aspect"] .ah-pc-fig .ah-frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

/* QUAD rows â€” four items in a single row (e.g. PBR texture map sets:
   BaseColor / Normal / ORM / ScatterMask). Always equal-height â€”
   cells share the --quad-aspect CSS var (default 1/1 from JS).
   object-fit: cover so different-aspect maps still align. */
:is(.ah-card, .ts-rich) .ah-pc-quad {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 6px;
  align-items: stretch;
}
:is(.ah-card, .ts-rich) .ah-pc-quad .ah-pc-fig {
  height: 100%;
}
:is(.ah-card, .ts-rich) .ah-pc-quad .ah-pc-fig .ah-frame {
  aspect-ratio: var(--quad-aspect, 1 / 1);
}
:is(.ah-card, .ts-rich) .ah-pc-quad .ah-pc-fig .ah-frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}
:is(.ah-card, .ts-rich) .ah-pc-quad .ah-pc-fig figcaption {
  font-size: 9.5px;
  letter-spacing: 0.04em;
}
/* Tablet / narrow desktop: 4-up gets cramped under ~620px container
   width. Fall to 2x2 â€” still keeps the "set of maps" gestalt. */
@media (max-width: 620px) {
  :is(.ah-card, .ts-rich) .ah-pc-quad {
    grid-template-columns: repeat(2, 1fr);
    gap: 8px;
  }
}

/* In-section bullet list â€” sits at the END of a processCard, BEFORE
   any `note` paragraph. Used by Vine's WPO blueprint section to
   describe the controls exposed by that specific blueprint. Same
   visual language as .ah-keypoints (the global wrap-up bullet list)
   so the asset card reads with one consistent bullet style â€” readers
   don't have to learn two. */
:is(.ah-card, .ts-rich) .ah-pc-points {
  list-style: none;
  margin: 12px 0 0;
  padding: 0;
}
:is(.ah-card, .ts-rich) .ah-pc-points li {
  position: relative;
  padding-left: 14px;
  margin-bottom: 8px;
  font-family: var(--font-ui);
  font-size: 11.5px;
  line-height: 1.55;
  color: var(--text-mid);
  letter-spacing: 0.005em;
}
:is(.ah-card, .ts-rich) .ah-pc-points li::before {
  content: "•";
  position: absolute;
  left: 2px;
  top: 0;
  color: var(--text-dim);
  font-size: 14px;
  line-height: 1.4;
}
:is(.ah-card, .ts-rich) .ah-pc-points li strong {
  color: var(--text);
  font-weight: 600;
}
:is(.ah-card, .ts-rich) .ah-pc-points li:last-child {
  margin-bottom: 0;
}

/* Grouped points â€” sub-headings + bullet groups inside a single
   processCard step. Used by Gazebo's Key Process section where
   topics (Simulation Mask Â· Velocity from Pyro Â· ...) each carry
   their own bullet list. The heading is a bold sentence-case label;
   bullets follow the same visual language as .ah-pc-points but
   stack closer together so the topic+bullets reads as one unit. */
:is(.ah-card, .ts-rich) .ah-pc-group {
  margin-top: 14px;
}
:is(.ah-card, .ts-rich) .ah-pc-group:first-child {
  margin-top: 16px;
}
:is(.ah-card, .ts-rich) .ah-pc-group-heading {
  font-family: var(--font-ui);
  font-size: 12px;
  font-weight: 600;
  color: var(--text);
  letter-spacing: 0.005em;
  margin-bottom: 6px;
}
:is(.ah-card, .ts-rich) .ah-pc-group-points {
  list-style: none;
  margin: 0;
  padding: 0;
}
:is(.ah-card, .ts-rich) .ah-pc-group-points li {
  position: relative;
  padding-left: 14px;
  margin-bottom: 6px;
  font-family: var(--font-ui);
  font-size: 11.5px;
  line-height: 1.55;
  color: var(--text-mid);
  letter-spacing: 0.005em;
}
:is(.ah-card, .ts-rich) .ah-pc-group-points li::before {
  content: "•";
  position: absolute;
  left: 2px;
  top: 0;
  color: var(--text-dim);
  font-size: 14px;
  line-height: 1.4;
}
:is(.ah-card, .ts-rich) .ah-pc-group-points li strong {
  color: var(--text);
  font-weight: 600;
}
:is(.ah-card, .ts-rich) .ah-pc-group-points li:last-child {
  margin-bottom: 0;
}

:is(.ah-card, .ts-rich) .ah-pc-note {
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px solid var(--hair);
  font-family: var(--font-ui);
  font-size: 11.5px;
  line-height: 1.6;
  color: var(--text-mid);
  letter-spacing: 0.005em;
}
@media (max-width: 600px) {
  /* Default pair behaviour on phone: stack vertically â€” gives each
     image full drawer width and more room for detail. */
  :is(.ah-card, .ts-rich) .ah-pc-pair {
    grid-template-columns: 1fr;
  }
  /* Equal-height (aspectRatio) pairs stay SIDE-BY-SIDE on phone â€” the
     whole point of declaring an aspect-ratio on a pair is to keep
     before/after visually aligned in one row. Stacking them defeats
     that pairing. The cells are smaller on phone but still equal
     height + same row, which honours the "ç”¨justifyçš„å½¢å¼åœ¨åŒä¸€è¡Œ,
     ç›¸åŒheight" intent. Slightly tighter gap + caption sizing below
     so the smaller cells don't feel cramped. */
  :is(.ah-card, .ts-rich) .ah-pc-pair[style*="--pair-aspect"] {
    grid-template-columns: 1fr 1fr;
    gap: 6px;
  }
  :is(.ah-card, .ts-rich) .ah-pc-pair[style*="--pair-aspect"] .ah-pc-fig figcaption {
    font-size: 9.5px;
    letter-spacing: 0.005em;
  }
}

/* Pipeline strip â€” n-wide image row */
.ah-card .ah-strip {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
  gap: 8px;
}
.ah-card .ah-strip figure {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 5px;
}
.ah-card .ah-strip .ah-frame {
  aspect-ratio: 1 / 1;
}
.ah-card .ah-strip figcaption {
  font-size: 9px;
  line-height: 1.35;
}

/* Bullet features */
.ah-card .ah-bullets {
  margin: 0;
  padding-left: 16px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.ah-card .ah-bullets li {
  font-family: var(--font-ui);
  font-size: 11.5px;
  line-height: 1.5;
  color: var(--text-mid);
  letter-spacing: 0.01em;
}
.ah-card .ah-bullets li strong,
.ah-card .ah-bullets li b { color: var(--text); font-weight: 500; }

/* Notes */
.ah-card .ah-note {
  font-family: var(--font-ui);
  font-size: 11px;
  line-height: 1.55;
  color: var(--text-mid);
  letter-spacing: 0.01em;
}

/* Footer â€” Output / Source / Pos */
.ah-card .ah-foot {
  padding: 12px 18px 14px;
  border-top: 1px solid var(--hair);
  display: flex;
  flex-direction: column;
  gap: 4px;
  background: rgba(255, 255, 255, 0.02);
}
.ah-card .ah-foot-row {
  display: flex;
  gap: 12px;
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.02em;
}
.ah-card .ah-foot-row .ah-k {
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.10em;
  width: 60px;
  flex-shrink: 0;
}
.ah-card .ah-foot-row .ah-v {
  color: var(--text-mid);
  flex: 1;
  word-break: break-word;
}
/* Citation links inside OUTPUT / SOURCE / POS footer rows + inside
 * processCard notes (Foliage's "Inspired by:" 80.lv block, etc.) —
 * inherit the body text colour rather than the browser default
 * blue/purple link palette. The monochrome project look would
 * otherwise break wherever a citation lives. Underline + a slightly
 * brighter colour on hover signal interactivity without breaking the
 * editorial palette. Visited state stays in the same dim-white range
 * so reading a citation once doesn't paint it purple forever. */
.ah-card .ah-foot a,
.ah-card .ah-foot a:visited,
:is(.ah-card, .ts-rich) .ah-pc-note a,
:is(.ah-card, .ts-rich) .ah-pc-note a:visited {
  color: var(--text-mid);
  text-decoration: underline;
  text-decoration-color: var(--hair-strong);
  text-underline-offset: 2px;
  transition: color 0.15s ease, text-decoration-color 0.15s ease;
}
.ah-card .ah-foot a:hover,
:is(.ah-card, .ts-rich) .ah-pc-note a:hover {
  color: var(--text);
  text-decoration-color: var(--text);
}

/* ============================================================
   Tech-spec drawer â€” rich content block (.ts-rich)

   Mirror of the asset-hover card's rich content (processCards /
   keyPoints / embed / simVideo) embedded inside Tech Breakdown
   drawer items. The .ah-pc-* / .ah-keypoints / .ah-embed-frame /
   .ah-sim-* selectors are already shared via :is(.ah-card, .ts-rich)
   higher up. This block fills in the small remaining gaps that were
   previously scoped only to .ah-card â€” the chip-style section title
   eyebrow, the base .ah-frame styling (no forced 1:1 aspect inside
   the drawer; image height drives its own frame), and a top spacer
   so the rich content sits a little lower than the item's note.
   ============================================================ */
.ts-rich {
  /* Drawer parity with the hover-card surface â€” without this the rich
     content reads as bare list content stuffed under a divider line,
     while the hover card frames the same content as a magazine spread.
     A 1px-lighter background + radius + soft inset shadow gives the
     rich content its own editorial container inside the drawer item.
     The asset-hover-card itself doesn't need this (the card surface
     IS the container); .ts-rich is the drawer-only twin. */
  margin-top: 14px;
  padding: 14px 14px 16px;
  background: rgba(255, 255, 255, 0.02);
  border: 1px solid rgba(255, 255, 255, 0.05);
  border-radius: 10px;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* ============================================================
   Editorial line-breaking â€” apply across every text-bearing class
   inside the asset-hover card AND the tech-spec drawer rich content.

   Two CSS properties cooperate:

   1. `hanging-punctuation: last allow-end` â€” terminal punctuation
      (period, comma, full-width ã€‚ ï¼Œ ã€ etc.) is allowed to hang
      PAST the line edge rather than starting the next visual line.
      This prevents the "lonely comma at line start" effect, which is
      a real readability bug in Chinese typography (where æ ‡ç‚¹ç¦åˆ™
      / kinsoku rules forbid it) and a polish issue in English too.

   2. `text-wrap: pretty` â€” newer browsers (Chrome 117+, Safari 18+)
      use a smarter line-break algorithm that minimises widows and
      orphans, balancing the last few lines so a single short word
      doesn't dangle on its own. Older browsers ignore it gracefully.

   Scoped via :is() to every text element that wraps inside a card.
   Skipped on chip rows + footers (no wrapping; single-line content). */
:is(.ah-card, .ts-rich) :is(
  .ah-pc-eyebrow,
  .ah-pc-title,
  .ah-pc-desc,
  .ah-pc-note,
  .ah-pc-points li,
  .ah-keypoints li,
  .ah-pc-fig figcaption,
  .ah-sec-title,
  .ah-note
),
.ah-card .ah-bullets li,
#tech-spec .ts-item-note,
#tech-spec .ts-item-sub,
#tech-spec .ts-zone-val,
.ts-compare .cmp-cap {
  hanging-punctuation: last allow-end;
  text-wrap: pretty;
}
/* Sections inside .ts-rich are layout-only â€” no border / wide padding
   like the asset-card uses. The drawer's .ts-item already provides the
   outer spacing and divider lines between items. */
.ts-rich .ah-section {
  padding: 0;
  border: 0;
}
.ts-rich .ah-section + .ah-section {
  margin-top: 4px;
}
/* Chip-style processCard header â€” matches the .ah-card .ah-sec-title
   uppercase eyebrow look so "MODELING AND OPTIMIZATION" reads the same
   inside the drawer as in the hover card. */
.ts-rich .ah-sec-title {
  font-family: var(--font-ui);
  font-size: 9px;
  font-weight: 500;
  letter-spacing: 0.20em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 6px;
}
/* Frame baseline for non-processCard slots (currently unused but
   keeps the look consistent if a future rich block emits a bare
   .ah-frame). The .ah-pc-fig .ah-frame override (above) handles the
   actual processCard frames. */
.ts-rich .ah-frame {
  border-radius: var(--radius-sm);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
}
.ts-rich .ah-frame img {
  width: 100%;
  height: auto;
  display: block;
}
.ts-rich figcaption {
  font-family: var(--font-ui);
  font-size: 10px;
  color: var(--text-mid);
  letter-spacing: 0.02em;
  text-align: center;
}

/* ============================================================
   Phone / iPad responsive adaptations for the rich content blocks
   and the new compare captions row.

   Strategy:
   - Phone (â‰¤480px): tighten font sizes, gaps, and stack the compare-
     grid + pair rows (already handled higher up via the dedicated
     media queries at 480px / 600px). Add tighter typography here so
     the bottom-sheet (mobile) and the drawer (mobile portrait) both
     read without horizontal crowding.
   - iPad portrait (481â€“900px): keep the side-by-side compare grids
     and pair rows â€” they look great at iPad widths and the existing
     stack-at-480 rule does not trigger. No extra rules needed beyond
     defaults. Only adjustment: slightly tighter compare-caption gap
     so two long labels don't visually merge in the middle.

   No rules target iPad specifically; iPad picks up either the default
   desktop sizing (â‰¥901px) or the 600px pair-stack rule depending on
   orientation, and both look right.
   ============================================================ */

/* iPad-class viewports + small laptop (481â€“900px) â€” compare captions
   benefit from a touch more breathing room because the side-by-side
   compare-grid cells are tighter here than on desktop. */
@media (min-width: 481px) and (max-width: 900px) {
  .ts-compare .cmp-captions {
    gap: 16px;
  }
  /* QUAD layout â€” 4 maps in a row at iPad-class widths is ~110px per
     cell inside the drawer (and tight even in the hover-card). Fall
     to 2x2 here; phones already get 2x2 via the 620px rule on .ah-pc-
     quad. This extends 2x2 coverage to iPad portrait + small laptop. */
  :is(.ah-card, .ts-rich) .ah-pc-quad {
    grid-template-columns: repeat(2, 1fr);
    gap: 8px;
  }
  /* Step typography eases off a touch at iPad widths so desktop sizes
     don't read oversized inside the 460px drawer / 600px hover-card. */
  :is(.ah-card, .ts-rich) .ah-pc-title {
    font-size: 15px;
  }
  :is(.ah-card, .ts-rich) .ah-pc-desc {
    font-size: 11.5px;
  }
  /* Drawer rich-content container â€” smaller inset padding so the
     pair / quad cells inside keep their effective row width. */
  .ts-rich {
    padding: 12px 12px 14px;
    gap: 14px;
  }
  /* Tech-spec section header â€” 26px UPPERCASE dominates inside a
     460px drawer. Bring the big title down so it doesn't overwhelm
     the per-item content below. */
  #tech-spec .ts-sec-name {
    font-size: 22px;
    letter-spacing: 0.05em;
  }
}

/* Phone LANDSCAPE â€” short viewport. Goal: keep the rich content
   visible above the fold without forcing the user past 2 paragraphs
   of description first. Tighten section spacing + ts-rich padding
   aggressively; the cards still read fine because the content is
   the same, just packed tighter. */
@media (orientation: landscape) and (max-height: 520px) {
  .ts-rich {
    margin-top: 10px;
    padding: 10px 12px 12px;
    gap: 12px;
  }
  :is(.ah-card, .ts-rich) .ah-pc-eyebrow {
    margin-bottom: 6px;
  }
  :is(.ah-card, .ts-rich) .ah-pc-title {
    margin-bottom: 8px;
  }
  :is(.ah-card, .ts-rich) .ah-pc-desc {
    font-size: 11px;
    line-height: 1.5;
    margin-bottom: 12px;
  }
  /* Tech Breakdown drawer also tightens its outer chrome on short
     landscape viewports â€” header, section header, section padding
     all trim so the rich content per item lands above the fold
     quicker. Drawer width on phone landscape is governed by the
     base min(460px, 92vw) which is fine at ~614px on a 667px
     viewport (not overridden by phone-portrait's 96vw rule since
     orientation: landscape disables that media query). */
  #tech-spec .ts-header {
    padding: 10px 14px 8px;
  }
  #tech-spec .ts-sub {
    padding: 6px 14px;
  }
  #tech-spec .ts-sec {
    padding: 12px 16px 10px;
  }
  #tech-spec .ts-sec-name {
    font-size: 20px;
    letter-spacing: 0.04em;
    line-height: 1.1;
  }
  #tech-spec .ts-sec-desc {
    padding: 6px 0 10px;
  }
  #tech-spec .ts-item-note {
    margin: 10px 0 0;
  }
}

@media (max-width: 480px) {
  /* Compare captions â€” drop a hair of font size + letter-spacing so
     the two-line wrap on long labels ("Before: Procedural base") still
     fits inside the narrow compare cell without the right-aligned side
     looking ragged. */
  .ts-compare .cmp-captions {
    font-size: 9px;
    letter-spacing: 0.06em;
    gap: 8px;
    margin-top: 6px;
  }
  /* Tech-spec drawer rich content â€” tighter vertical rhythm so the
     drawer doesn't feel airy on a 375px viewport. */
  .ts-rich {
    margin-top: 10px;
    gap: 11px;
  }
  /* Step-style processCard typography â€” eyebrow + title + desc all
     come down a half-step. The clamp on .ah-pc-title already pins to
     14px at narrow widths; we trim the margin-bottom under it so the
     description prose starts closer to the title. */
  :is(.ah-card, .ts-rich) .ah-pc-eyebrow {
    font-size: 9.5px;
    letter-spacing: 0.18em;
    margin-bottom: 8px;
  }
  :is(.ah-card, .ts-rich) .ah-pc-title {
    margin-bottom: 10px;
  }
  :is(.ah-card, .ts-rich) .ah-pc-desc {
    font-size: 11.5px;
    line-height: 1.55;
    margin-bottom: 14px;
  }
  /* Tighter row spacing â€” small phones can't afford 12px between every
     media row, especially when the user scrolls a long 03-Texturing
     section with multiple compares stacked. */
  :is(.ah-card, .ts-rich) .ah-pc-row {
    margin-bottom: 10px;
  }
  :is(.ah-card, .ts-rich) .ah-pc-row.ah-pc-compare {
    margin-bottom: 14px;
  }
  /* Keypoints â€” slightly smaller body and tighter bullets */
  :is(.ah-card, .ts-rich) .ah-keypoints li {
    font-size: 11.5px;
    line-height: 1.55;
    margin-bottom: 7px;
  }
  /* Tech Breakdown drawer panel â€” gain ~12px of content area on phones
     (drawer was 92vw = 345px on a 375px iPhone; now 96vw = 360px).
     Modest gain but it lets pair rows inside ts-rich breathe without
     stacking. The remaining 4vw left margin keeps the drawer visually
     distinct from the splat scene behind it. */
  #tech-spec .ts-panel {
    width: 96vw;
  }
  /* Section header â€” 26px UPPERCASE is too dominant inside a 360px
     drawer. Scale down so the section title cues the section without
     overwhelming the per-item content that follows it. */
  #tech-spec .ts-sec-name {
    font-size: 19px;
    letter-spacing: 0.04em;
    line-height: 1.15;
  }
  #tech-spec .ts-sec-num {
    font-size: 11px;
    min-width: 24px;
    margin-top: 6px;
  }
  #tech-spec .ts-sec-count {
    font-size: 8.5px;
  }
  /* Section outer padding tighter on phone â€” recovers ~12px of content
     width vs the default 16/18. */
  #tech-spec .ts-sec {
    padding: 14px 14px 12px;
  }
  /* Section description prose smaller â€” matches the new section name
     scale + the narrower content width. */
  #tech-spec .ts-sec-desc {
    font-size: 11.5px;
    padding: 6px 0 12px;
  }
  /* Item-level inner padding stays put; only the section outer needs
     tuning. Item name reduced a half-step to harmonise with the
     smaller section title above it. */
  #tech-spec .ts-item-name {
    font-size: 14.5px;
  }
  #tech-spec .ts-item-sub {
    font-size: 10px;
  }
  #tech-spec .ts-item-note {
    font-size: 11.5px;
    margin: 12px 0 0;
  }
  /* ts-rich already gets tighter margin/gap from the rule above;
     reduce its inset padding too so pair cells inside don't lose
     width to chrome. Effective content area after this: roughly
     360 (drawer) - 28 (.ts-sec) - 28 (.ts-rich) = 304px. */
  .ts-rich {
    padding: 11px 11px 13px;
    border-radius: 8px;
  }
  /* Drawer header trim for phone â€” the "TECH BREAKDOWN" title is
     authoritative but the surrounding chrome (sub-line, close button)
     eats vertical space. Tighten without losing legibility. */
  #tech-spec .ts-header {
    padding: 12px 14px 10px;
  }
  #tech-spec .ts-sub {
    padding: 8px 14px;
    font-size: 9.5px;
  }
}

/* ============================================================
 * Credits â€” centered slide-up panel (team + software)
 * ============================================================ */
#credits {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, calc(-50% + 24px));
  width: min(520px, calc(100vw - 32px));
  max-height: calc(100vh - 80px);
  z-index: 1100;
  display: flex;
  flex-direction: column;
  /* Dark frosted-glass background — opacity tuned so the blurred
   * splat scene reads visibly behind the panel without competing
   * with the body text. The first iteration of this fix landed at
   * 0.94 (nearly solid) which killed all backdrop-blur visibility;
   * 0.78 brings back the "frosted depth" feel — you can see the
   * splat field hinted through the panel as soft colour wash —
   * while keeping the team / thanks / software rows on a stable
   * surface. Blur radius bumped from 28px → 36px so the wash
   * behind reads as ambient atmosphere rather than recognisable
   * scene detail; saturation lifted to 160 % for richer colour
   * through the glass. */
  background: rgba(10, 10, 12, 0.78);
  border: 1px solid var(--hair-strong);
  border-radius: var(--radius);
  backdrop-filter: blur(36px) saturate(160%);
  -webkit-backdrop-filter: blur(36px) saturate(160%);
  box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.28s ease, transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
  overflow: hidden;
  font-family: var(--font-ui);
}
#credits.show {
  opacity: 1;
  pointer-events: auto;
  transform: translate(-50%, -50%);
}
#credits .cr-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 18px;
  border-bottom: 1px solid var(--hair);
  background: rgba(255, 255, 255, 0.03);
  cursor: grab;            /* header is the drag handle */
  user-select: none;
}
#credits.dragging .cr-head { cursor: grabbing; }
#credits.dragging { transition: none; }
#credits .cr-title {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--text);
}
#credits .cr-close {
  /* Fixed 28x28 box + flex centring + explicit padding:0 (overrides the
     UA <button> default `1px 6px` that was crashing the box width).
     flex-shrink: 0 stops the flex parent (.cr-head) from squashing
     the button â€” without it the button shrank to ~14px wide. */
  background: transparent;
  border: 0;
  box-sizing: border-box;
  flex-shrink: 0;
  color: var(--text-dim);
  font-size: 20px;
  font-family: var(--font-ui);
  line-height: 1;
  cursor: pointer;
  width: 28px;
  height: 28px;
  padding: 0;
  border-radius: 4px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 0.15s ease, color 0.15s ease;
}
#credits .cr-close:hover { color: var(--text); background: rgba(255, 255, 255, 0.08); }
#credits .cr-body {
  padding: 18px 20px 22px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 22px;
  /* flex: 1 + min-height: 0 lets the body actually consume the
   * available height inside #credits (which is a flex column with a
   * capped max-height) AND shrink below its intrinsic content size so
   * overflow-y: auto kicks in. Without min-height: 0 the body defaults
   * to min-height: auto which is its content height, so on a short
   * landscape phone viewport the body grew past the parent and got
   * clipped by the parent's overflow: hidden — the user saw the
   * Credits header and footer but the middle was invisible. */
  flex: 1 1 auto;
  min-height: 0;
}
#credits .cr-sec-title {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.20em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 10px;
}
/* About block â€” opens the Credits panel with project-level context BEFORE
   the team / software / tech-stack sections. Typography intentionally
   mirrors the loading-splash & cinematic-flourish hero card (.ld-title)
   so the project's three "bookend" surfaces speak the same language. */
#credits .cr-about {
  padding-bottom: 22px;
  border-bottom: 1px solid var(--hair, rgba(255, 255, 255, 0.08));
  text-align: center;
}
#credits .cr-about-eyebrow {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.20em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 8px;
}
#credits .cr-about-title {
  font-family: var(--font-ui, var(--font-mono));
  font-size: clamp(28px, 4vw, 38px);
  font-weight: 300;
  letter-spacing: -0.025em;
  line-height: 1;
  color: var(--text);
  margin: 0 0 6px 0;
}
#credits .cr-about-sub {
  font-family: var(--font-ui, var(--font-mono));
  font-size: 12px;
  font-weight: 300;
  letter-spacing: 0.02em;
  color: var(--text-mid);
  margin-bottom: 10px;
}
#credits .cr-about-stack {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.06em;
  color: var(--text-dim);
}

/* Quiet hardware-acknowledgement line at the bottom of the Credits panel.
   Sits below Tech Stack, separated by a hairline so it reads as a
   distinct "small print" thank-you rather than another featured chip. */
#credits .cr-hw-thanks {
  margin-top: 4px;
  padding-top: 18px;
  border-top: 1px solid var(--hair, rgba(255, 255, 255, 0.08));
  font-family: var(--font-mono);
  font-size: 10px;
  line-height: 1.6;
  color: var(--text-dim);
  text-align: center;
  letter-spacing: 0.02em;
}
#credits .cr-hw-thanks strong {
  color: var(--text-mid);
  font-weight: 500;
  letter-spacing: 0.01em;
}
#credits .cr-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
#credits .cr-row {
  display: grid;
  grid-template-columns: 1fr auto auto;
  align-items: center;
  gap: 14px;
  padding: 9px 4px;
  border-bottom: 1px solid var(--hair);
}
#credits .cr-row:last-child { border-bottom: 0; }
#credits .cr-name {
  font-size: 13px;
  color: var(--text);
  font-weight: 500;
}
/* Special Thanks rows skip the middle "role" column â€” just name + link. */
#credits .cr-row-thanks {
  grid-template-columns: 1fr auto;
}
#credits .cr-role {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--text-dim);
}
#credits .cr-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 4px;
  color: var(--text-mid);
  text-decoration: none;
  transition: background 0.15s ease, color 0.15s ease;
}
#credits .cr-link svg { width: 14px; height: 14px; }
#credits .cr-link:hover { color: var(--text); background: rgba(255, 255, 255, 0.08); }
#credits .cr-link-empty { color: var(--text-dim); opacity: 0.35; cursor: not-allowed; }
#credits .cr-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
#credits .cr-chip {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.04em;
  padding: 4px 9px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--hair);
  color: var(--text-mid);
}
/* Featured chip â€” Unreal/Houdini in Software, HP AI Studio/OpenUSD in
 * Tech Stack. Wears a brighter background + slightly larger type so
 * the eye lands on the hero entries first. */
#credits .cr-chip-featured {
  background: rgba(255, 255, 255, 0.13);
  border-color: rgba(255, 255, 255, 0.35);
  color: var(--text);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.06em;
  padding: 5px 11px;
}
/* "Also: ..." footnote under the chip grid â€” for software used minimally
   that deserves credit but not equal billboard space. Sits below the
   chips at small mono weight so the visual hierarchy stays clear. */
#credits .cr-also {
  margin-top: 8px;
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.04em;
  color: var(--text-dim);
}

/* ============================================================
 * IntroOverlay â€” hero title + phase callouts over the auto-play
 * camera move (first visit only)
 * ============================================================ */
#intro-overlay {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 950;
  font-family: var(--font-ui);
}
#intro-overlay[hidden] { display: none; }

#intro-overlay .io-hero {
  position: absolute;
  top: 38%;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
  opacity: 0;
  transition: opacity 0.18s linear;
  text-shadow: 0 2px 30px rgba(0, 0, 0, 0.75);
}
/* Mirrors #loading .ld-title so the splash and the intro overlay show
 * the same "SplatGarden" wordmark in the same face / weight / spacing. */
#intro-overlay .io-hero-title {
  font-family: var(--font-ui);
  font-size: clamp(56px, 9vw, 104px);
  font-weight: 300;
  letter-spacing: -0.025em;
  line-height: 0.95;
  color: #fff;
  margin: 0;
}
#intro-overlay .io-hero-sub {
  margin-top: 14px;
  font-family: var(--font-mono);
  font-size: clamp(11px, 1.1vw, 14px);
  letter-spacing: 0.36em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.78);
}

#intro-overlay .io-phase {
  position: absolute;
  left: 4vw;
  bottom: 8vh;
  max-width: 480px;
  opacity: 0;
  transition: opacity 0.18s linear;
  padding-left: 14px;
  border-left: 2px solid rgba(255, 255, 255, 0.85);
  text-shadow: 0 2px 20px rgba(0, 0, 0, 0.8);
}
#intro-overlay .io-phase-eyebrow {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.36em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.82);
  margin-bottom: 8px;
}
#intro-overlay .io-phase-text {
  font-size: clamp(22px, 3vw, 38px);
  font-weight: 300;
  color: #fff;
  letter-spacing: 0.02em;
  line-height: 1.1;
}

#intro-overlay .io-bottom-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: rgba(255, 255, 255, 0.10);
}
#intro-overlay .io-bottom-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, rgba(255,255,255,0.45), rgba(255,255,255,0.95));
  transition: width 0.08s linear;
}

/* ============================================================
 * OnboardingPointers â€” animated arrows + label cards pointing at
 * T (Pipeline) / K (Tuner) / Scene panel after the intro
 * ============================================================ */
#onboarding-pointers {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 1050;
  font-family: var(--font-ui);
}
#onboarding-pointers[hidden] { display: none; }
/* Hide onboarding pointers on phones â€” the bottom-bar tabs (Tour /
   Effects / Studio / Info / Share) are already labelled, so the
   floating callouts duplicate information AND overlap each other
   horribly (three â‰ˆ 320 px wide nowrap cards trying to anchor above
   five â‰ˆ 64 px wide bar tabs in the same row produce the stack the
   user reported). Tablet + desktop keep the pointers â€” both have
   wider viewports and panels without printed labels. */
body.mobile #onboarding-pointers { display: none !important; }

#onboarding-pointers .op-tip {
  /* Position + transform are set inline by JS (left/top = anchor point
     on the target's edge, transform = align tip's own arrow-side edge
     to that anchor). We animate opacity only so the JS transform isn't
     fought over by a CSS keyframe. */
  position: absolute;
  display: flex;
  align-items: center;
  gap: 10px;
  opacity: 0;
  transition: opacity 0.35s ease;
  color: rgba(255, 255, 255, 0.92);
}
#onboarding-pointers.show .op-tip {
  opacity: 1;
  transition-delay: var(--delay, 0s);
}

/* Flex direction per side so the arrow always sits on the side of the
   card that faces the target (e.g. tip placed to the LEFT of the
   target â†’ arrow on the RIGHT of the card, pointing right at it). */
#onboarding-pointers .op-side-right { flex-direction: row;            }
#onboarding-pointers .op-side-left  { flex-direction: row-reverse;    }
#onboarding-pointers .op-side-above { flex-direction: column;         }
#onboarding-pointers .op-side-below { flex-direction: column-reverse; }

#onboarding-pointers .op-card {
  background: rgba(15, 16, 18, 0.78);
  border: 1px solid rgba(255, 255, 255, 0.22);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  padding: 10px 14px;
  border-radius: 8px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
  white-space: nowrap;
  text-align: center;
  max-width: min(70vw, 320px);
}
#onboarding-pointers .op-label {
  font-size: 13px;
  font-weight: 500;
  color: #fff;
  letter-spacing: 0.02em;
}
#onboarding-pointers .op-sub {
  margin-top: 3px;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.65);
}

/* Wrapper does the per-side rotation; the inner SVG does the bob, so
   the two transforms compose without stomping each other. The SVG
   itself draws a right-pointing arrow at rest; the wrapper rotates it
   to face the target. */
#onboarding-pointers .op-arrow-wrap {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
#onboarding-pointers .op-side-right .op-arrow-wrap { transform: rotate(180deg); }  /* arrow points LEFT toward target */
#onboarding-pointers .op-side-left  .op-arrow-wrap { transform: rotate(0deg);   }  /* arrow points RIGHT toward target */
#onboarding-pointers .op-side-above .op-arrow-wrap { transform: rotate(90deg);  }  /* arrow points DOWN toward target */
#onboarding-pointers .op-side-below .op-arrow-wrap { transform: rotate(-90deg); }  /* arrow points UP toward target */

#onboarding-pointers .op-arrow {
  width: 80px;
  height: 40px;
  color: rgba(255, 255, 255, 0.85);
  filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
  animation: op-arrow-bob 1.6s ease-in-out infinite;
}
/* Positive translateX in the arrow's local frame leans the arrowhead
   toward the target after the wrapper's rotation â€” works correctly for
   every side (rotated frames remap +X to the correct visual axis). */
@keyframes op-arrow-bob {
  0%, 100% { transform: translateX(0); }
  50%      { transform: translateX(6px); }
}

/* ============================================================
 * UsdLayers panel â€” 3DGS / USD layer rows with eye toggles, subform
 * pills, and inline size sliders. Visual language matches the Scene
 * panel (same eye buttons, same row shape).
 * ============================================================ */
#usd-layers-panel {
  /* Default standalone style â€” kept as a fallback if the embedded path
   * fails (e.g. lil-gui internals change). The .usd-embedded class
   * below overrides everything once the panel mounts inside lil-gui. */
  position: fixed;
  top: 18px;
  right: 18px;
  width: 280px;
  max-height: 420px;
  z-index: 1002;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--panel);
  backdrop-filter: blur(28px) saturate(140%);
  -webkit-backdrop-filter: blur(28px) saturate(140%);
  border: 1px solid var(--hair);
  border-radius: var(--radius);
  box-shadow: var(--shadow-soft);
}
/* Embedded inside lil-gui â€” strip the floating-panel chrome so the
 * panel reads as the topmost section of the existing lil-gui surface.
 * Width inherits, max-height drops (lil-gui scrolls), background and
 * border collapse away. */
#usd-layers-panel.usd-embedded {
  position: static;
  width: auto;
  max-width: 100%;
  max-height: none;
  z-index: auto;
  background: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  border: 0;
  border-bottom: 1px solid var(--hair);
  border-radius: 0;
  box-shadow: none;
  margin: 0;
  overflow: visible;
}
#usd-layers-panel.usd-embedded .usd-row-list {
  overflow: visible;
  padding: 4px 6px 8px;
}
/* (UsdLayers no longer renders its own header â€” the surrounding
   lil-gui folder "3DGS / USD" provides the title, caret, and fold
   behaviour. The .usd-embedded class strips the panel's standalone
   chrome so only the folder's treatment shows.) */
#usd-layers-panel .usd-hint {
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.38);
  padding: 4px 10px 6px;
  max-height: 24px;
  overflow: hidden;
  transition: opacity 0.4s ease, max-height 0.35s ease, padding 0.35s ease;
}
/* Hint auto-retires once the user has clicked any toggle â€” its job is
   teaching multi-select; after the first toggle the user has proven
   they understood. Fades out + collapses, persists across sessions. */
#usd-layers-panel .usd-hint.dismissed {
  opacity: 0;
  max-height: 0;
  padding-top: 0;
  padding-bottom: 0;
  pointer-events: none;
}
/* The embedded variant inherits the same subtle text-link styling
   defined for the base .usd-upload â€” no overrides needed now that
   the upload action is a discrete link rather than a block button. */
/* (Header / fold-caret / folded-state rules removed â€” UsdLayers no
   longer manages its own folding. The wrapping lil-gui folder owns
   that now. See the .usd-hint rule above for the only piece of
   header-y chrome we still render: a small uppercase cue line above
   the toggle rows that explains "stack freely".) */
#usd-layers-panel header .title {
  font-family: var(--font-ui);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-mid);
}
/* "Use My Own" â€” *rare* secondary action. Styled as a subtle text
   link rather than a button so the visual weight is clearly less
   than the primary controls (toggle switches + subform pills). This
   was an outlined block-button before, which read at the same weight
   as the actual primary controls â€” false hierarchy, distracting. Now
   it's tucked at the bottom as discreet small-caps text the user can
   discover but won't fight for attention. */
#usd-layers-panel .usd-upload {
  display: block;
  margin: 6px auto 10px;
  padding: 5px 12px;
  background: transparent;
  color: rgba(255, 255, 255, 0.45);
  border: 0;
  border-radius: 6px;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 9.5px;
  font-weight: 400;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  text-align: center;
  transition: color 0.15s ease, background 0.15s ease;
}
#usd-layers-panel .usd-upload:hover {
  color: rgba(255, 255, 255, 0.85);
  background: rgba(255, 255, 255, 0.04);
}
#usd-layers-panel.folded .usd-upload { display: none; }
#usd-layers-panel.folded .usd-upload-row { display: none; }

/* Max-range input + Use My Own button — laid out side by side so the
 * upload button and its companion cap sit on one row. Input is small
 * because most users won't touch it (auto is the right default);
 * the label is a mono sub-cap so it reads as a setting, not a primary
 * affordance. */
#usd-layers-panel .usd-upload-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  flex-wrap: wrap;
  margin: 6px 0 10px;
}
#usd-layers-panel .usd-max-range {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--text-dim);
  cursor: help;
}
#usd-layers-panel .usd-max-range-input {
  width: 56px;
  padding: 3px 6px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair-strong);
  border-radius: 3px;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.04em;
  text-align: right;
  /* Strip Chrome / Firefox spinner buttons — the numeric input still
   * works fine with keyboard / paste / typing, and the spinner UI
   * fights the editorial-monochrome look. */
  appearance: textfield;
  -webkit-appearance: textfield;
  -moz-appearance: textfield;
}
#usd-layers-panel .usd-max-range-input::-webkit-outer-spin-button,
#usd-layers-panel .usd-max-range-input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}
#usd-layers-panel .usd-max-range-input:focus {
  outline: none;
  border-color: var(--text-dim);
  background: rgba(255, 255, 255, 0.07);
}
#usd-layers-panel .usd-upload-row .usd-upload {
  margin: 0;
}
#usd-layers-panel .usd-row-list {
  list-style: none;
  margin: 0;
  padding: 6px;
  overflow-y: auto;
  flex: 1;
}
/* Section header â€” quiet label matching lil-gui's folder title weight.
 * No blurb line, no bold display type; the section is just a marker
 * between groups of rows so the panel reads like a tighter Post-Process
 * folder rather than its own visual world. */
#usd-layers-panel .usd-section-head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 8px 8px 2px;
  margin-top: 2px;
}
#usd-layers-panel .usd-section-head:first-child {
  margin-top: 0;
  padding-top: 2px;
}
#usd-layers-panel .usd-section-name {
  font-family: var(--font-ui);
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-mid);
  line-height: 1;
}
#usd-layers-panel .usd-section-blurb { display: none; }

#usd-layers-panel .usd-row {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 5px 8px;
  border-radius: var(--radius-sm);
  font-size: 11.5px;
  color: var(--text);
  transition: background 0.12s;
}
#usd-layers-panel .usd-row:hover { background: var(--hover); }
#usd-layers-panel .usd-row.hidden-row .usd-name,
#usd-layers-panel .usd-row.hidden-row .usd-badge,
#usd-layers-panel .usd-row.hidden-row .usd-pill-group,
#usd-layers-panel .usd-row.hidden-row .usd-size { opacity: 0.40; }

#usd-layers-panel .usd-row-main {
  display: flex;
  align-items: center;
  gap: 8px;
}
/* iOS-style ON/OFF switch per row. Replaces the older eye-icon button
   because (a) the switch's pill+knob shape inherently signals "this is
   one of multiple independent toggles you can mix freely", and (b) the
   eye icon was easy to mistake for a "preview / hover" affordance.
   Each row's switch operates independently â€” layers stack. */
#usd-layers-panel .usd-row .usd-toggle {
  position: relative;
  width: 34px;
  height: 20px;
  flex: 0 0 auto;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.10);
  border: 1px solid rgba(255, 255, 255, 0.10);
  cursor: pointer;
  padding: 0;
  transition: background 0.18s ease, border-color 0.18s ease;
}
/* Invisible 44 Ã— 44 hit zone via ::before â€” the visible pill is 34 Ã— 20
   (a polished iOS-style switch reads small) but fingers need more
   slop. The pseudo-element extends the tap target outward by 5 px
   horizontally and 12 px vertically so any reasonable finger landing
   on the switch row registers as a hit. Stays transparent â€” purely
   for touch reliability. Doesn't extend rightward enough to overlap
   the layer name to the toggle's right. */
#usd-layers-panel .usd-row .usd-toggle::before {
  content: "";
  position: absolute;
  inset: -12px -5px;
  border-radius: 999px;
}
#usd-layers-panel .usd-row .usd-toggle .usd-toggle-knob {
  position: absolute;
  top: 1px;
  left: 1px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.78);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
  /* Snappy snap-back curve with a tiny overshoot â€” like iOS's switch
     animation. The knob lands a hair past its target, then settles.
     Gives the toggle a "decided" feel. */
  transition: transform 0.28s cubic-bezier(.34, 1.56, .64, 1),
              background 0.18s ease;
}
#usd-layers-panel .usd-row .usd-toggle.on {
  background: rgba(255, 255, 255, 0.92);
  border-color: rgba(255, 255, 255, 0.92);
}
#usd-layers-panel .usd-row .usd-toggle.on .usd-toggle-knob {
  transform: translateX(14px);
  background: #0b0f14;
}
#usd-layers-panel .usd-row .usd-toggle:active .usd-toggle-knob {
  width: 18px;   /* tiny "press" stretch */
}
#usd-layers-panel .usd-name {
  font-weight: 500;
  letter-spacing: 0.01em;
  font-size: 11.5px;
}
#usd-layers-panel .usd-badge {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: 0.05em;
  color: var(--text-dim);
  text-transform: none;
  background: transparent;
  border: 0;
  border-radius: 0;
  padding: 0;
  white-space: nowrap;
}
/* "view-only" tag — small monochrome pill on non-interactive rows
 * (Billboard, Voxel) telling the reader those layers don't take mouse
 * clicks or hover hotspots. Sits inline between the row name and the
 * tech badge; reads as metadata, not as a control. Hover surfaces
 * the full explanation via the title tooltip. */
#usd-layers-panel .usd-noninteract {
  font-family: var(--font-mono);
  font-size: 8px;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--text-dim);
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--hair-strong);
  padding: 2px 6px;
  border-radius: 2px;
  white-space: nowrap;
  cursor: help;
}
#usd-layers-panel .usd-row.hidden-row .usd-noninteract { opacity: 0.4; }

#usd-layers-panel .usd-row-sub {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding-left: 28px;          /* align under the name, past the eye */
}
#usd-layers-panel .usd-pill-group {
  display: inline-flex;
  align-items: stretch;
  gap: 0;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
  border-radius: 5px;
  padding: 2px;
  align-self: flex-start;
}
#usd-layers-panel .usd-pill {
  background: transparent;
  border: 0;
  color: var(--text-mid);
  font-family: var(--font-ui);
  font-size: 11px;
  letter-spacing: 0.02em;
  padding: 4px 11px;
  border-radius: 3px;
  cursor: pointer;
  transition: background 0.12s, color 0.12s;
}
#usd-layers-panel .usd-pill:hover { color: var(--text); }
#usd-layers-panel .usd-pill.active {
  background: rgba(255, 255, 255, 0.12);
  color: var(--text);
  font-weight: 500;
}

#usd-layers-panel .usd-size {
  display: grid;
  grid-template-columns: 84px 1fr 48px;
  align-items: center;
  gap: 8px;
}
#usd-layers-panel .usd-size-label {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--text-dim);
}
#usd-layers-panel .usd-size input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 4px;
  background: rgba(255, 255, 255, 0.10);
  border-radius: 2px;
  outline: none;
  cursor: pointer;
}
#usd-layers-panel .usd-size input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--text);
  border: 0;
  cursor: pointer;
}
#usd-layers-panel .usd-size input[type="range"]::-moz-range-thumb {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--text);
  border: 0;
  cursor: pointer;
}
#usd-layers-panel .usd-size-val {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-mid);
  text-align: right;
}

/* ============================================================
 * UsdAnnotations â€” museum-style annotation card that pops when the
 * user manually activates a layer in the 3DGS/USD panel.
 * Anchored upper-right, slides in from the right edge with a thin
 * connector rule on the left so the eye reads it as pinned to the
 * scene rather than floating in the chrome.
 * ============================================================ */
/* UsdAnnotations â€” redesigned to follow the user's iOS reference
   (Outer Hebrides weather widget). The card now has the same
   information hierarchy as that reference:

     â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
     â”‚ EYEBROW Â· UPPERCASE         Ã— close â”‚
     â”‚ Title (big)              HeroValue â–¶â”‚
     â”‚ Friendly intro line in plain Englishâ”‚
     â”‚ â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”          â”‚
     â”‚ â”‚ icon  k  â”‚  â”‚ icon  k  â”‚          â”‚ â† 2Ã—2 grid
     â”‚ â”‚       v  â”‚  â”‚       v  â”‚          â”‚
     â”‚ â””â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”˜  â””â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”˜          â”‚
     â”‚ â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”          â”‚
     â”‚ â”‚ icon  k  â”‚  â”‚ icon  k  â”‚          â”‚
     â”‚ â”‚       v  â”‚  â”‚       v  â”‚          â”‚
     â”‚ â””â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”˜  â””â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”˜          â”‚
     â”‚ italic body â€” "why we built this"   â”‚
     â””â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”˜

   Anchored upper-left of centre (not pinned to the lil-gui rail any
   more) so it reads as a free-floating glass widget instead of a
   tooltip hanging off the panel. Same heavy frosted-glass treatment
   as the Studio panel for visual continuity. */
#usd-annotations {
  position: fixed;
  /* Anchored to the RIGHT side of the viewport â€” sits adjacent to
     the lil-gui Studio panel (which is `right: 18 / width: 280`),
     so the card lands right where the user's eye already is after
     tapping a layer toggle. Was previously centred; the user asked
     for it to "sit closer to the right UI". 322 px = 18 (gui right
     offset) + 280 (gui width) + 24 (gap). */
  top: 80px;
  right: 322px;
  left: auto;
  width: min(360px, calc(100vw - 32px));
  z-index: 1080;
  font-family: var(--font-ui);
  color: var(--text);
  /* Heavy frosted-glass card matching the iOS reference. Subtle
     warm tint instead of pure dark so the card has the same
     creamy quality as the reference's "Outer Hebrides" widget. */
  background: rgba(26, 28, 34, 0.62);
  backdrop-filter: blur(36px) saturate(170%) brightness(1.04);
  -webkit-backdrop-filter: blur(36px) saturate(170%) brightness(1.04);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 22px;
  padding: 16px 18px 16px;
  box-shadow:
    0 20px 56px rgba(0, 0, 0, 0.50),
    0 1px 0 rgba(255, 255, 255, 0.08) inset;
  opacity: 0;
  pointer-events: none;
  transform: translateY(-8px);
  transition: opacity 0.28s ease,
              transform 0.34s cubic-bezier(0.22, 1, 0.36, 1);
  /* Draggable affordance â€” the card is grabbable anywhere except
     the close button + interactive elements (those keep default
     cursor via the inner rules). Switches to grabbing while drag
     is active (.dragging class set by usd-annotations.js). */
  cursor: grab;
  user-select: none;
  -webkit-user-select: none;
  touch-action: none;
}
#usd-annotations[hidden] { display: none; }
#usd-annotations.show {
  opacity: 1;
  pointer-events: auto;
  transform: translateY(0);
}
#usd-annotations.dragging {
  cursor: grabbing;
  /* Disable the transform transition during drag so the card sticks
     to the finger / cursor without easing. Re-enabled when the
     drag ends and .dragging is removed. */
  transition: none;
}
/* Interactive elements inside the card revert to a normal pointer
   cursor (so the user knows they can click these without lifting
   the whole card). */
#usd-annotations .ua-close,
#usd-annotations .ua-tile {
  cursor: pointer;
}

/* Eyebrow caption â€” small uppercase mono line above the title row.
   Same role as "H:24Â° L:8Â° 6:01" in the reference. */
#usd-annotations .ua-eyebrow {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.22em;
  color: rgba(255, 255, 255, 0.55);
  margin-bottom: 6px;
  padding-right: 32px;        /* leaves room for the Ã— button */
}

/* Close button â€” small circular chip top-right. */
#usd-annotations .ua-close {
  position: absolute;
  top: 12px;
  right: 12px;
  width: 26px;
  height: 26px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.10);
  color: rgba(255, 255, 255, 0.72);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
#usd-annotations .ua-close svg { width: 12px; height: 12px; display: block; }
#usd-annotations .ua-close:hover {
  background: rgba(255, 255, 255, 0.14);
  color: #fff;
}
#usd-annotations .ua-close:active { transform: scale(0.92); }

/* Title row â€” big title on the left, hero value on the right.
   Mirrors "Outer Hebrides | 12Â°" balance. */
#usd-annotations .ua-titleRow {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px;
}
#usd-annotations .ua-title {
  margin: 0;
  font-size: 24px;
  font-weight: 600;
  letter-spacing: 0;
  line-height: 1.1;
  color: #fff;
}
#usd-annotations .ua-hero {
  font-family: var(--font-ui);
  font-size: 22px;
  font-weight: 300;
  color: rgba(255, 255, 255, 0.92);
  letter-spacing: 0;
  white-space: nowrap;
  flex-shrink: 0;
}

/* Intro â€” ONE friendly plain-English sentence. Top tier of the
   three-tier information ladder (intro â†’ grid â†’ body). */
#usd-annotations .ua-intro {
  margin: 8px 0 14px;
  font-size: 12.5px;
  line-height: 1.55;
  color: rgba(255, 255, 255, 0.86);
  letter-spacing: 0.005em;
}

/* 2Ã—2 fact grid â€” translucent inner tiles, each with an icon, a
   tiny mono label, and a value. Matches the Wind / Humidity /
   UV / Rain layout in the reference. */
#usd-annotations .ua-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
  margin-bottom: 14px;
}
#usd-annotations .ua-tile {
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.07);
  border-radius: 14px;
  padding: 10px 12px;
  display: grid;
  grid-template-columns: 18px 1fr;
  grid-template-rows: auto auto;
  column-gap: 8px;
  row-gap: 1px;
  align-items: center;
}
#usd-annotations .ua-tile-icon {
  grid-column: 1;
  grid-row: 1 / span 2;
  width: 18px;
  height: 18px;
  color: rgba(255, 255, 255, 0.78);
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
#usd-annotations .ua-tile-icon svg { width: 18px; height: 18px; display: block; }
#usd-annotations .ua-tile-k {
  grid-column: 2; grid-row: 1;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.48);
  line-height: 1.2;
}
#usd-annotations .ua-tile-v {
  grid-column: 2; grid-row: 2;
  font-size: 12px;
  font-weight: 500;
  color: #fff;
  letter-spacing: 0.005em;
  line-height: 1.3;
  /* Allow the value to wrap to a 2nd / 3rd line instead of getting
     truncated by an ellipsis â€” the previous `white-space: nowrap`
     + `text-overflow: ellipsis` clipped "Anisotropic 3D Gaussian"
     to "Anisotropic 3D Gâ€¦" and lost the key information. The grid
     row auto-sizes so the tile grows vertically to fit; the iOS
     reference's short values ("5 km/h") never needed wrap, but
     our longer USD terms do. word-break: break-word so any single
     value longer than the tile width still fits without overflowing
     the card. */
  white-space: normal;
  overflow: visible;
  word-break: break-word;
}

/* Italic body â€” "why we built this" paragraph. Lower weight, soft
   colour, italic so it reads as a quieter footnote than the intro
   above. Same role as the gray italic blurb at the bottom of the
   reference card. */
#usd-annotations .ua-body {
  margin: 0;
  font-size: 11.5px;
  font-style: italic;
  line-height: 1.55;
  color: rgba(255, 255, 255, 0.62);
  letter-spacing: 0.005em;
  text-align: center;
}

/* Narrow viewports â€” drop to a single column for the fact tiles so
   the values don't get truncated to ellipses, and shrink padding
   so the card still floats in the centre without dominating the
   viewport. */
@media (max-width: 480px) {
  #usd-annotations {
    width: calc(100vw - 24px);
    padding: 14px 14px 14px;
  }
  #usd-annotations .ua-grid {
    grid-template-columns: 1fr;
  }
  #usd-annotations .ua-title { font-size: 21px; }
  #usd-annotations .ua-hero  { font-size: 19px; }
}

/* ============================================================
 * MobileNav â€” hamburger trigger + slide-down drawer (touch only)
 * ============================================================ */
#mobile-nav-btn {
  position: fixed;
  top: 14px;
  right: 14px;
  width: 44px;
  height: 44px;
  border-radius: 10px;
  border: 1px solid var(--hair-strong);
  background: rgba(15, 16, 18, 0.78);
  color: var(--text);
  cursor: pointer;
  display: none;            /* shown on .touch only */
  align-items: center;
  justify-content: center;
  z-index: 1500;
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4);
  transition: background 0.15s ease, border-color 0.15s ease;
}
#mobile-nav-btn svg {
  width: 22px;
  height: 22px;
  /* Slight cross-fade between the hamburger â†” Ã— glyphs feels less abrupt
     when the JS swaps innerHTML. Transition is on the wrapping svg
     attributes (opacity), but the actual cross-fade is implicit since
     innerHTML replacement is instant â€” this just keeps the existing
     line work crisp during press. */
  transition: transform 0.18s ease;
}
#mobile-nav-btn:hover         { background: rgba(28, 30, 34, 0.85); }
#mobile-nav-btn:active        { background: rgba(40, 42, 48, 0.90); transform: scale(0.96); }
/* Open state: subtle lift so users can tell at a glance whether the
   drawer is open without having to spot the Ã— vs. â‰¡ glyph. */
#mobile-nav-btn[aria-expanded="true"] {
  background: rgba(28, 30, 34, 0.92);
  border-color: rgba(255, 255, 255, 0.18);
}
/* Hamburger is dormant on phone â€” every menu item moved into the bottom-
   bar sheets (Info / Camera). On tablet the hamburger isn't shown either
   (its display: none default applies â€” body.touch was the only thing
   that would have shown it, and tablets now skip that path). */
body.mobile #mobile-nav-btn { display: none !important; }

#mobile-nav-menu {
  position: fixed;
  top: 66px;
  right: 14px;
  min-width: 220px;
  max-width: calc(100vw - 28px);
  background: rgba(15, 16, 18, 0.92);
  border: 1px solid var(--hair-strong);
  border-radius: 12px;
  z-index: 1499;
  padding: 6px;
  backdrop-filter: blur(18px) saturate(140%);
  -webkit-backdrop-filter: blur(18px) saturate(140%);
  box-shadow: 0 18px 48px rgba(0, 0, 0, 0.5);
  font-family: var(--font-ui);
}
#mobile-nav-menu[hidden] { display: none; }
#mobile-nav-menu .mn-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
#mobile-nav-menu .mn-list button {
  width: 100%;
  display: grid;
  grid-template-columns: 24px 1fr auto;
  align-items: center;
  gap: 12px;
  padding: 12px 12px;
  background: transparent;
  border: 0;
  border-radius: 8px;
  color: var(--text);
  font-family: inherit;
  font-size: 14px;
  text-align: left;
  cursor: pointer;
}
#mobile-nav-menu .mn-list button:active,
#mobile-nav-menu .mn-list button:hover {
  background: rgba(255, 255, 255, 0.07);
}
#mobile-nav-menu .mn-icon {
  /* Hosts an inline SVG icon (see mobile-nav.js ICON map). Use flex so
     the SVG centres within the column lil-gui grid cell regardless of
     intrinsic SVG height. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--text-mid);
  width: 22px;
  height: 22px;
}
#mobile-nav-menu .mn-icon svg {
  width: 20px;
  height: 20px;
  display: block;
}
/* Active / hover row pulls the icon a touch brighter for an extra
   tactile cue alongside the row background flash. */
#mobile-nav-menu .mn-list button:hover  .mn-icon,
#mobile-nav-menu .mn-list button:active .mn-icon { color: rgba(255, 255, 255, 0.92); }
#mobile-nav-menu .mn-key {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.12em;
  color: var(--text-dim);
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--hair);
  border-radius: 4px;
  padding: 2px 6px;
}

/* Status-bar crossfade â€” when #status text changes meaning (not the
   silent FPS suffix), it flashes a quick fade + slide so the change
   reads as intentional motion rather than a snap. Driven by a
   MutationObserver in main.js that only re-adds the class on
   meaningful changes. */
@keyframes status-flash {
  0%   { opacity: 0.35; transform: translateY(3px); filter: blur(1.2px); }
  100% { opacity: 1;    transform: translateY(0);   filter: blur(0); }
}
#status.status-flash {
  animation: status-flash 0.32s cubic-bezier(.16,1,.3,1);
}

/* ============================================================
 * Choreographed entrance â€” once the splash hides, the major UI
 * surfaces fade in with a staggered sequence so the viewer's eye
 * naturally goes hero â†’ side panels â†’ toolbar. Pure CSS, fires
 * once on page load. The `body.ui-ready` class is added by main.js
 * right after hideLoading() so the animation never starts behind
 * a splash. Each surface uses `animation-fill-mode: backwards`
 * so it's invisible BEFORE its delay (prevents a flash).
 * ============================================================ */
/* Keyframes intentionally OPACITY-ONLY (no transform) for any element
   that has backdrop-filter. Chromium drops backdrop-filter pass-through
   whenever an ancestor sits in a non-identity transform context â€” even
   after the animation completes, `transform: translateX(0)` from
   `animation-fill-mode: both` keeps the stacking context alive and the
   glass blur breaks. Plain opacity has no such side-effect.

   CRITICAL â€” animate the LEAF panel, not its wrapper. Putting an
   `animation` on `#left-stack` (a parent of `#sidebar` + `#scene-panel`)
   promotes #left-stack onto its own compositor layer; once that happens
   its DESCENDANTS' backdrop-filter can only sample inside that layer,
   so #sidebar and #scene-panel render as flat tinted panels with no
   blur of the 3D scene behind them. lil-gui works because its
   animation is on `.lil-gui.root` â€” the same element that owns the
   backdrop-filter â€” so the filter samples its own layer's input
   (which IS the canvas). We follow the same pattern for the left
   stack: animate #sidebar + #scene-panel directly, leave #left-stack
   untouched. #hand-panel owns its backdrop-filter so it can keep the
   animation on itself.

   The bottom toolbar + Studio button DON'T contain backdrop-filter
   children, so they can keep the translate motion. */
@keyframes ui-enter-opacity {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@keyframes ui-enter-translate {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}
body.ui-ready .lil-gui.root {
  animation: ui-enter-opacity 0.55s cubic-bezier(.16,1,.3,1) 0.08s both;
}
/* Animate the panels DIRECTLY, not their #left-stack wrapper â€”
   see comment above. Two-step stagger inside the left column. */
body.ui-ready #sidebar {
  animation: ui-enter-opacity 0.55s cubic-bezier(.16,1,.3,1) 0.20s both;
}
body.ui-ready #scene-panel {
  animation: ui-enter-opacity 0.55s cubic-bezier(.16,1,.3,1) 0.26s both;
}
body.ui-ready #hand-panel {
  animation: ui-enter-opacity 0.50s cubic-bezier(.16,1,.3,1) 0.32s both;
}
body.ui-ready #toolbar {
  animation: ui-enter-translate 0.50s cubic-bezier(.16,1,.3,1) 0.40s both;
}
body.ui-ready #mobile-bottombar {
  /* Opacity-only â€” the bar's `transform: translateX(-50%)` is what
     centers it horizontally, so a translate-based animation would
     fight the centering and momentarily slide it off-centre. */
  animation: ui-enter-opacity 0.55s cubic-bezier(.16,1,.3,1) 0.12s both;
}
body.ui-ready #mobile-studio-btn {
  animation: ui-enter-opacity 0.50s cubic-bezier(.16,1,.3,1) 0.22s both;
}
/* Asset hotspots pulse in after the chrome UI â€” final layer in the
   cinematic reveal sequence. They use opacity-only because the
   per-frame transform is driven by the projection update. */
@keyframes ui-hotspot-fade { from { opacity: 0; } to { opacity: 1; } }
body.ui-ready .asset-hotspot {
  animation: ui-hotspot-fade 0.6s ease both;
  /* Per-dot stagger added on top of the existing 0.55s post-splash
   * delay. --i is set inline on each dot by asset-hover.js (its
   * position in the items array), so the dots cascade in 50 ms apart
   * after the chrome UI settles. Reads as choreography rather than a
   * synchronised pop. */
  animation-delay: calc(0.55s + var(--i, 0) * 50ms);
}

/* ============================================================
 * Settings-saved micro-toast (window.__toast) — single-instance
 * status pill that pops at the bottom-right for ~1.5 s when a
 * persistent setting flips (Color tint on/off, Preset change,
 * Snapshot saved, etc.). Defaults to hidden; .show fades it in
 * with a small slide; the JS clears .show after the duration.
 * On phone-portrait the bar moves up so it doesn't collide with
 * the floating MobileUI bottom toolbar.
 * ============================================================ */
#ui-toast {
  position: fixed;
  right: 18px;
  bottom: 18px;
  z-index: 1300;          /* above lil-gui (1001), above onboarding (1050), below drawer (5000) */
  padding: 8px 14px;
  background: var(--panel-strong);
  border: 1px solid var(--hair-strong);
  border-radius: 4px;
  backdrop-filter: blur(20px) saturate(140%);
  -webkit-backdrop-filter: blur(20px) saturate(140%);
  box-shadow: var(--shadow-soft);
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 11px;
  letter-spacing: 0.02em;
  pointer-events: none;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.18s ease, transform 0.18s ease;
}
#ui-toast.show {
  opacity: 1;
  transform: translateY(0);
}
body.phone-device #ui-toast {
  /* Lift above the floating bottom toolbar on phone-portrait so the
   * toast is readable without colliding with the bar. */
  bottom: 92px;
  right: 12px;
}

/* ============================================================
 * FPS first-person walking — virtual joystick visuals
 * ============================================================
 * The joystick lives in the DOM (not the canvas) because the
 * canvas owns the WebGL context and is touch-event-only. Spawned
 * dynamically by fps-controls.js when a touch lands on the left
 * half of the canvas; positioned via transform: translate() so
 * the browser composites on the GPU and touchmove never triggers
 * layout. Removed on touchend. Two elements: the "base" ring
 * pinned at the anchor, and the "stick" disc that follows the
 * finger clamped to a 60 px radius.
 *
 * Visual language: monochrome whites with low alpha, hairline
 * borders, no shadows. Matches the project's editorial palette so
 * the joystick reads as part of the UI vocabulary rather than a
 * game-engine overlay. */
.fps-joy-base,
.fps-joy-stick {
  position: fixed;
  top: 0;
  left: 0;
  pointer-events: none;
  z-index: 1250;
  border-radius: 50%;
  /* The element is anchored at the centre of its bounding box via
   * margin so transform: translate() lands on the touch position. */
}
.fps-joy-base {
  width: 132px;
  height: 132px;
  margin: -66px 0 0 -66px;
  border: 1px solid rgba(255, 255, 255, 0.30);
  background: rgba(255, 255, 255, 0.04);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  animation: fps-joy-fade-in 0.18s ease-out;
}
.fps-joy-stick {
  width: 44px;
  height: 44px;
  margin: -22px 0 0 -22px;
  border: 1px solid rgba(255, 255, 255, 0.55);
  background: rgba(255, 255, 255, 0.14);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  animation: fps-joy-fade-in 0.18s ease-out;
}
@keyframes fps-joy-fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* Optional: a faint "drag here to walk / look" hint that lives on
 * the canvas only while FPS is active and no joystick is touched.
 * Cheap discoverability for first-time touch users — they see
 * the affordance instead of having to guess. */
body.fps-active.touch::before,
body.fps-active.touch::after {
  content: "";
  position: fixed;
  top: 0;
  bottom: 0;
  width: 50vw;
  pointer-events: none;
  z-index: 1240;
  /* Subtle gradient hint at the divider line — fades to fully
   * transparent at the screen edges so the canvas reads cleanly,
   * and is brightest at the centre seam between walk + look zones. */
  background: linear-gradient(90deg,
    transparent 0%,
    rgba(255, 255, 255, 0) 60%,
    rgba(255, 255, 255, 0.03) 100%);
  animation: fps-hint-fade 5s ease-out forwards;
}
body.fps-active.touch::after {
  left: 50vw;
  transform: scaleX(-1);  /* mirror the gradient so both sides darken inward */
}
body.fps-active.touch::before {
  left: 0;
}
@keyframes fps-hint-fade {
  0%   { opacity: 1; }
  90%  { opacity: 1; }
  100% { opacity: 0; }
}

/* ============================================================
 * First-paint reveal — full-viewport black veil between canvas
 * (z-index 0) and loading splash (z-index 10+). Created in JS
 * at boot so it's already painted before the splash hides;
 * body.ui-ready transitions the opacity over 1.5 s, dissolving
 * the held black frame into the live splat.
 * ============================================================ */
#first-paint-veil {
  position: fixed;
  inset: 0;
  background: #000;
  z-index: 2;
  pointer-events: none;
  opacity: 1;
  transition: opacity 1.5s ease-out;
}
body.ui-ready #first-paint-veil {
  opacity: 0;
}

/* ============================================================
 * Replay fade-to-black — half-second darken before the page
 * reload that __replayIntro performs. Sits above EVERYTHING
 * (z-index 99999) so it covers the cinematic UI while the
 * reload spins up. pointer-events:none lets the click that
 * triggered the fade still register on the underlying button.
 * ============================================================ */
body.replay-fading::after {
  content: "";
  position: fixed;
  inset: 0;
  background: #000;
  pointer-events: none;
  z-index: 99999;
  animation: replay-fade 0.5s ease forwards;
}
@keyframes replay-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* ============================================================
 * Idle pulse — when body.idle is set after 5 s of no input,
 * the hotspot dots amplify their ambient pulse and cycle the
 * "loud moment" through dots one at a time via per-dot --i
 * delay. First-time visitors who haven't noticed the small
 * static pulses get a moment of "look, these are clickable"
 * without the dots becoming distracting for engaged readers.
 * ============================================================ */
body.idle .asset-hotspot .ahot-ring {
  animation: ahotIdlePulse 6s ease-in-out infinite;
  animation-delay: calc(var(--i, 0) * 1.0s);
}
@keyframes ahotIdlePulse {
  0%, 85%, 100% { opacity: 0.35; transform: scale(1.00); }
  12%           { opacity: 1.00; transform: scale(2.50); }
}

/* ============================================================
 * MobileUI — bottom toolbar + slide-up sheet + asset toast.
 * Scoped to body.mobile (phone only). Tablets get the desktop UI
 * because iPad's screen is wide enough for the lil-gui + sidebar
 * pattern and the user wants iPad === PC.
 * ============================================================ */
/* Floating capsule bottom-bar â€” Apple Health / Portfolite-y idiom.
   Lifted off the viewport edge with a generous radius pill and a soft
   ambient drop-shadow. Width hugs its content; horizontally centred
   so the whole bar feels like one polished object instead of a strip
   bolted to the bottom of the screen. */
#mobile-bottombar {
  position: fixed;
  left: 50%;
  bottom: calc(14px + env(safe-area-inset-bottom, 0px));
  transform: translateX(-50%);
  height: 64px;
  padding: 6px 8px;
  display: none;             /* shown on body.mobile only */
  align-items: stretch;
  gap: 2px;
  z-index: 1500;
  background: rgba(15, 16, 20, 0.78);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 999px;
  box-shadow: 0 16px 44px rgba(0, 0, 0, 0.55),
              0 4px 14px rgba(0, 0, 0, 0.4),
              inset 0 1px 0 rgba(255, 255, 255, 0.06);
  backdrop-filter: blur(24px) saturate(160%);
  -webkit-backdrop-filter: blur(24px) saturate(160%);
  font-family: var(--font-ui);
}
body.mobile #mobile-bottombar { display: flex; }
#mobile-bottombar button {
  flex: 0 0 auto;
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  background: transparent;
  border: 0;
  color: rgba(255, 255, 255, 0.50);
  font-family: inherit;
  font-size: 10px;
  letter-spacing: 0.04em;
  cursor: pointer;
  padding: 6px 14px;
  border-radius: 999px;
  min-width: 56px;
  transition: color 0.18s ease, background 0.18s ease;
}
#mobile-bottombar button .mb-ico {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  /* Tiny lift on the active icon â€” purely tactile, no layout shift. */
  transition: transform 0.18s ease;
}
#mobile-bottombar button .mb-ico svg { width: 22px; height: 22px; display: block; }
#mobile-bottombar button:active           { background: rgba(255, 255, 255, 0.05); }
#mobile-bottombar button:hover            { color: rgba(255, 255, 255, 0.78); }
/* Active state: soft rounded pill + brighter icon/label + the icon
   lifts very slightly. The pill replaces the old 22 px underline dash
   â€” it's much more readable in low-light viewport overlays and ties
   the tab visually to the sheet content above. */
#mobile-bottombar button.active {
  color: rgba(255, 255, 255, 0.98);
  background: rgba(255, 255, 255, 0.08);
}
#mobile-bottombar button.active .mb-ico { transform: translateY(-1px); color: rgba(255, 255, 255, 0.98); }

/* Center "Studio" slot â€” the project's primary action (3DGS / USD
   showcase). Visually elevated above the other tabs so the eye lands
   there first. Slightly wider, brighter ring on idle, fuller fill +
   subtle glow when active. Same pattern as Apple Health's centre +
   button or Wallet's Pay button: one obvious focal action surrounded
   by secondary affordances. */
#mobile-bottombar button.mb-center {
  min-width: 64px;
  color: rgba(255, 255, 255, 0.92);
  background: rgba(255, 255, 255, 0.05);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
#mobile-bottombar button.mb-center .mb-ico {
  width: 26px;
  height: 26px;
  color: rgba(255, 255, 255, 0.96);
}
#mobile-bottombar button.mb-center .mb-ico svg { width: 26px; height: 26px; }
#mobile-bottombar button.mb-center:hover {
  background: rgba(255, 255, 255, 0.09);
}
#mobile-bottombar button.mb-center.active {
  color: #fff;
  background: rgba(255, 255, 255, 0.14);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.26),
              0 0 22px rgba(255, 255, 255, 0.08);
}

/* (No backdrop â€” the sheet is non-modal. Scene above stays bright +
   interactive. User closes via the Ã— button, swipe-down on the
   handle, or re-tap of the active bottom-bar tab.) */

/* ---- Sheet ----
   Sized tightly to its content (flex: 0 0 auto on the body) so a
   3-item list doesn't take half the viewport. Capped at max-height
   for content that genuinely needs scroll. Portfolite-inspired:
   larger corner radius, softer borders, more breathing room. */
/* Bottom sheet â€” restyled to match the iOS reference (Outer Hebrides
   weather widget) the user supplied. Was previously a full-width slab
   bolted to the bottom edge with only the top corners rounded; now it
   floats as a centred glass CARD with all four corners rounded, sitting
   ABOVE the capsule bottom-bar (not flush against it), with the same
   luminous frosted glass + warm tint the Studio panel + USD annotation
   card use. Visual continuity across Tour / Effects / Info â†’ Studio
   so every bottom-bar tab reads as part of the same family. */
#mobile-sheet {
  position: fixed;
  left: 50%;
  bottom: calc(64px + 14px + env(safe-area-inset-bottom, 0px) + 14px); /* clears the floating bar */
  width: calc(100vw - 24px);
  max-width: 480px;
  max-height: min(70vh, 560px);
  z-index: 1496;
  background: rgba(26, 28, 34, 0.62);
  backdrop-filter: blur(36px) saturate(170%) brightness(1.04);
  -webkit-backdrop-filter: blur(36px) saturate(170%) brightness(1.04);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 22px;
  font-family: var(--font-ui);
  color: var(--text);
  display: flex;
  flex-direction: column;
  padding-bottom: 14px;
  /* Compose translate: X centres the card horizontally; Y starts off-
     screen (below the bottom edge) and slides up to 0 on .open. */
  transform: translateX(-50%) translateY(100vh);
  transition: transform 0.32s cubic-bezier(.22, 1, .36, 1);
  box-shadow:
    0 24px 60px rgba(0, 0, 0, 0.55),
    0 1px 0 rgba(255, 255, 255, 0.08) inset;
}
#mobile-sheet.open      { transform: translateX(-50%) translateY(0); }
#mobile-sheet.dragging  { transition: none; }   /* finger-follow is direct */

#mobile-sheet .ms-handle {
  height: 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: grab;
  touch-action: none;
}
#mobile-sheet .ms-handle::before {
  content: "";
  width: 36px;
  height: 4px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.18);
}
#mobile-sheet .ms-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 4px 20px 12px;
  /* No border â€” the title's typographic weight + spacing carries the
     section separation. Cleaner, more Portfolite-y. */
}
#mobile-sheet .ms-title {
  font-size: 17px;
  font-weight: 500;
  letter-spacing: -0.01em;
  color: rgba(255, 255, 255, 0.96);
}
#mobile-sheet .ms-close {
  width: 36px;
  height: 36px;
  border-radius: 999px;
  border: 0;
  background: rgba(255, 255, 255, 0.04);
  color: rgba(255, 255, 255, 0.65);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 0.15s ease, color 0.15s ease;
}
#mobile-sheet .ms-close:active {
  background: rgba(255, 255, 255, 0.10);
  color: rgba(255, 255, 255, 0.95);
}
#mobile-sheet .ms-body {
  padding: 4px 20px 16px;
  overflow: auto;
  /* Size to content (no flex-grow). Sheet height = handle + head +
     body content + padding-bottom. Capped by the sheet's max-height
     when content gets very tall â€” in which case overflow:auto on
     this body keeps scrolling inside the sheet. */
  flex: 0 1 auto;
  min-height: 0;
  -webkit-overflow-scrolling: touch;
  /* Tab-switch crossfade: BottomSheet.show() adds `.swapping` for
     ~130 ms while it swaps innerHTML between tabs (Tour â†’ Effects â†’
     Info). Without this, the content hard-snaps and reads as
     "panel cleared then refilled". With it, the sheet feels like a
     single surface that morphs between tabs. */
  transition: opacity 0.13s ease;
  opacity: 1;
}
#mobile-sheet .ms-body.swapping {
  opacity: 0;
}

/* ---- Reusable sheet primitives (Portfolite-y: softer borders, larger
   radii, more generous padding-vs-content ratio) ---- */
#mobile-sheet .ms-row-label {
  font-size: 11px;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.42);
  margin: 4px 0 10px;
}
/* Second-and-later section label inside the same sheet body â€” adds
   visual separation between merged sections (e.g. Tour's Viewpoints
   list above + Camera Movement controls below). Without this the
   "CAMERA MOVEMENT" header would visually touch the last viewpoint
   row, reading as a label of THAT row rather than a new section. */
#mobile-sheet .ms-row-label ~ .ms-row-label {
  margin-top: 18px;
  padding-top: 14px;
  border-top: 1px solid rgba(255, 255, 255, 0.06);
}
#mobile-sheet .ms-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 18px;
}
#mobile-sheet .ms-chip {
  font-family: inherit;
  font-size: 13px;
  padding: 9px 14px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.10);
  background: rgba(255, 255, 255, 0.03);
  color: rgba(255, 255, 255, 0.84);
  cursor: pointer;
  transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
#mobile-sheet .ms-chip.on {
  background: rgba(255, 255, 255, 0.94);
  color: #111316;
  border-color: rgba(255, 255, 255, 0.94);
}
#mobile-sheet .ms-chip:active { transform: scale(0.97); }

#mobile-sheet .ms-toggle-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 0;
  /* Replace the heavy border with a hairline that doesn't carve up
     the sheet visually â€” closer to the Portfolite list-row idiom. */
  border-top: 1px solid rgba(255, 255, 255, 0.06);
}
#mobile-sheet .ms-toggle-label { font-size: 14px; color: rgba(255, 255, 255, 0.92); }
#mobile-sheet .ms-switch {
  position: relative;
  width: 46px;
  height: 28px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.10);
  border: 1px solid rgba(255, 255, 255, 0.10);
  cursor: pointer;
  transition: background 0.18s ease;
}
#mobile-sheet .ms-switch.on { background: rgba(255, 255, 255, 0.92); border-color: rgba(255, 255, 255, 0.92); }
#mobile-sheet .ms-switch .ms-switch-knob {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: #fff;
  transition: transform 0.18s ease, background 0.18s ease;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
#mobile-sheet .ms-switch.on .ms-switch-knob { transform: translateX(18px); background: #111316; }

#mobile-sheet .ms-action {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  margin-top: 10px;
  padding: 14px 16px;
  font-family: inherit;
  font-size: 14px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 14px;
  color: rgba(255, 255, 255, 0.92);
  cursor: pointer;
  transition: background 0.15s ease, transform 0.15s ease;
}
#mobile-sheet .ms-action:active { transform: scale(0.99); background: rgba(255, 255, 255, 0.07); }
#mobile-sheet .ms-action-primary {
  background: rgba(255, 255, 255, 0.94);
  color: #111316;
  border-color: rgba(255, 255, 255, 0.94);
  font-weight: 500;
}
#mobile-sheet .ms-action.ms-advanced {
  justify-content: space-between;
  margin-top: 16px;
  background: transparent;
}
#mobile-sheet .ms-action.ms-advanced .ms-arrow {
  font-size: 18px;
  color: rgba(255, 255, 255, 0.48);
}
#mobile-sheet .ms-help {
  margin-top: 14px;
  font-size: 12.5px;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.46);
}
#mobile-sheet .ms-icon-glyph {
  font-size: 14px;
  line-height: 1;
  opacity: 0.85;
}

/* ---- Views list (tight, refined rows; no heavy dividers) ---- */
#mobile-sheet .ms-views-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
#mobile-sheet .ms-views-list li {
  display: grid;
  grid-template-columns: 28px 1fr auto;
  align-items: center;
  gap: 14px;
  padding: 11px 10px;
  border-radius: 12px;
  cursor: pointer;
  transition: background 0.15s ease;
}
#mobile-sheet .ms-views-list li:active { background: rgba(255, 255, 255, 0.05); }
#mobile-sheet .ms-views-list li.active { background: rgba(255, 255, 255, 0.07); }
#mobile-sheet .ms-vp-num {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.08em;
  color: rgba(255, 255, 255, 0.48);
  border: 1px solid rgba(255, 255, 255, 0.10);
  border-radius: 8px;
  padding: 3px 0;
  text-align: center;
}
#mobile-sheet .ms-views-list li.active .ms-vp-num {
  color: rgba(255, 255, 255, 0.88);
  border-color: rgba(255, 255, 255, 0.20);
}
#mobile-sheet .ms-vp-name { font-size: 15px; color: rgba(255, 255, 255, 0.92); }
#mobile-sheet .ms-vp-badge {
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.14em;
  color: rgba(255, 255, 255, 0.42);
  text-transform: uppercase;
}

/* ---- Info sheet grid ---- */
#mobile-sheet .ms-info-grid {
  display: grid;
  grid-template-columns: max-content 1fr;
  column-gap: 18px;
  row-gap: 10px;
  margin-bottom: 12px;
  font-family: var(--font-mono);
  font-size: 12px;
}
#mobile-sheet .ms-info-k {
  color: var(--text-dim);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  font-size: 10.5px;
}
#mobile-sheet .ms-info-v { color: var(--text); }

/* ---- Advanced (full lil-gui) wrapper inside the sheet ---- */
#mobile-sheet .ms-gui-wrap {
  /* lil-gui sets its own dark backdrop; reset margins so it fills width */
  width: 100%;
}
#mobile-sheet .ms-gui-wrap > .lil-gui.root {
  position: static !important;
  width: 100% !important;
  max-width: 100% !important;
  max-height: none !important;
  margin: 0 !important;
}

/* ---- Empty state ---- */
#mobile-sheet .ms-empty {
  padding: 18px 0;
  text-align: center;
  color: var(--text-dim);
  font-size: 13px;
}

/* ---- Asset detail card inside the sheet ----
   The renderCard() output is the SAME on phone (here) and desktop
   (the floating #asset-hover-card). The .ah-card class is on both
   wrappers, so all the rich content styling â€” chips, triptych, sim
   video, before/after compare, etc. â€” applies identically. Below we
   only override the sheet-specific bits: hide the redundant Ã—
   (sheet has its own close), drop the in-card chrome that would
   double up with the sheet header. */
#mobile-sheet .ms-asset-card .ah-close { display: none; }
/* The mobile bottom-sheet renders its own title in the sheet header
   (from this.sheet.show("asset", it.name, ...)), so the card's INTERNAL
   .ah-head (which also renders the asset name) was producing the
   duplicate "Vine / Vine" the user reported on mobile. Hide it â€” the
   sheet header is the canonical title; the body starts with toolchain
   / embed / etc. straight away. */
#mobile-sheet .ms-asset-card .ah-head { display: none; }

/* ---- Toast ---- */
#mobile-toast {
  position: fixed;
  top: calc(70px + env(safe-area-inset-top, 0px));
  left: 50%;
  transform: translateX(-50%) translateY(-6px);
  z-index: 1700;
  pointer-events: none;
  max-width: calc(100vw - 32px);
  padding: 9px 14px;
  background: rgba(15, 16, 18, 0.92);
  border: 1px solid var(--hair-strong);
  border-radius: 999px;
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 12.5px;
  letter-spacing: 0.02em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: 0;
  transition: opacity 0.18s ease, transform 0.18s ease;
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
}
#mobile-toast.show {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

/* ---- Phone-only layout shifts (push existing things out of the way) ---- */
/* Hide the desktop bottom-right brand/status bar â€” the new bottom-bar
   replaces it, and they fight for the same corner. (Tablet keeps the
   desktop toolbar since the bottom-bar isn't shown there.) */
body.mobile #toolbar { display: none !important; }
/* Phone landscape ALSO hides the bottom-left status strip
   (dimensions readout + "click the splat to fire X" hint). The
   existing rule above only covers phone PORTRAIT because
   `.mobile` toggles off when the iPhone rotates and the width
   crosses 768 px. The hardware-bound `.phone-device` class
   survives that flip, so this rule keeps the status hidden on
   the same physical phone regardless of which way it's held â€”
   nobody reads a one-line technical status on a phone, and the
   strip was floating over the splat in landscape per the user's
   screenshot. */
body.phone-device #toolbar { display: none !important; }
/* Lift the Quick Guide above the new bottom-bar so it stops being
   covered by it. 54 (bar) + 18 (gap) + safe-area. */
body.mobile #key-hints {
  /* Lift above the floating capsule bottom-bar (64 px tall, 14 px lift
     from the bottom edge, plus the device safe-area). */
  bottom: calc(92px + env(safe-area-inset-bottom, 0px)) !important;
}
/* Hide the floating lil-gui at top-right on PHONE â€” bottom-bar's
   Effects sheet now owns "Open Advanced" as the canonical entry point.
   Undo the hide when the gui has been re-parented INTO the sheet so
   power users get the full panel when they ask for it. Tablets keep
   the standalone lil-gui (matches desktop UX). */
body.mobile .lil-gui.root           { display: none !important; }
body.mobile #mobile-sheet .lil-gui.root { display: block !important; }
/* Camera-move timeline + intro overlay phase text both default to
   bottom-anchored coordinates that pre-date the bottom-bar. Without
   these overrides they stack on top of the bar AND each other during
   intro playback (CAPTURE eyebrow / "Captured in Unreal Engine" / the
   timeline F-43/397 strip all collide in ~50 px of vertical space).
   Lift each one above the previous so the stack reads cleanly:
     [phase text]                â† bottom: 150 + safe-area
     [timeline strip]            â† bottom: 72  + safe-area
     [bottom bar (54px + safe)]  â† bottom: 0 */
body.mobile .cam-timeline {
  /* Above the floating capsule bar (64 + 14 lift + safe-area). */
  bottom: calc(92px + env(safe-area-inset-bottom, 0px));
}
body.mobile #intro-overlay .io-phase {
  bottom: calc(170px + env(safe-area-inset-bottom, 0px));
}

/* ============================================================
 * MobileStudioPanel â€” top-right floating 3DGS / USD entry point.
 * The button slot is the same spot the (now-empty) hamburger used to
 * occupy â€” keeps the top corners symmetric. The drop-down panel hosts
 * the live #usd-layers-panel (re-parented at open time) so all its
 * existing eye toggles + subform pills + size sliders work as-is.
 * ============================================================ */
#mobile-studio-btn {
  position: fixed;
  top: 14px;
  right: 14px;
  width: 44px;
  height: 44px;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.14);
  background: rgba(13, 14, 17, 0.84);
  color: rgba(255, 255, 255, 0.84);
  cursor: pointer;
  display: none;            /* shown on body.mobile only */
  align-items: center;
  justify-content: center;
  z-index: 1502;
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45),
              inset 0 1px 0 rgba(255, 255, 255, 0.05);
  transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
/* DEPRECATED on phone â€” Studio now lives in the centre slot of the
   bottom bar (#mobile-bottombar button.mb-center). Keep the floating
   button hidden so users get one canonical Studio entrypoint. The
   #mobile-studio-panel itself is still triggered from the centre tab
   via MobileStudioPanel._show(), so its drop-down behaviour + USD
   layer DOM re-parenting remain unchanged. */
body.mobile #mobile-studio-btn { display: none; }
#mobile-studio-btn svg { width: 22px; height: 22px; display: block; }
#mobile-studio-btn:active { transform: scale(0.96); background: rgba(28, 30, 34, 0.92); }
#mobile-studio-btn[aria-expanded="true"] {
  background: rgba(255, 255, 255, 0.10);
  border-color: rgba(255, 255, 255, 0.32);
  color: #fff;
}

/* Studio panel â€” repositioned per UX/UI/PM analysis the user
   requested. WAS: top: 66 / right: 14 (top-right drop-down), which
   broke the spatial relationship with its trigger button â€” users
   tapped the centre "Studio" slot in the bottom-bar and the panel
   appeared diagonally across the screen. Three issues with that:
     1. Eye context-switch from tap point to result location.
     2. Inconsistent with Tour / Effects / Info, which already use
        a centred bottom-sheet pattern â€” one mental model for the
        bar's left tabs, a different one for the centre tab.
     3. One-handed reach: top-right is the FURTHEST spot from a
        thumb resting on the bottom-bar.
   NOW: centred horizontally, anchored above the floating capsule
   bar â€” same coords as #mobile-sheet, same translateX(-50%) +
   translateY() compose, same heavy frosted-glass treatment as the
   USD annotation card. Slides up from the bar rather than dropping
   in from the corner, so the affordance reads as "the centre tab
   opens this card right above where you tapped". */
#mobile-studio-panel {
  position: fixed;
  left: 50%;
  bottom: calc(64px + 14px + env(safe-area-inset-bottom, 0px) + 14px);
  width: calc(100vw - 24px);
  max-width: 380px;
  /* Cap tuned for the AR-compact row layout WITH the size slider
     restored. A typical row is â‰ˆ 70 px when the slider is showing
     (40 px header line + 30 px slider line). Three rows Ã— 70 +
     header 80 + upload pill 60 â‰ˆ 350 px, plus breathing room.
     Still well below the old â‰ˆ 560 px when each layer was a
     vertically-stacked card. */
  max-height: min(440px, calc(100vh - 100px - (54px + env(safe-area-inset-bottom, 0px))));
  z-index: 1501;            /* below the trigger (1502), above bottom-bar (1500) */
  background: rgba(26, 28, 34, 0.62);
  backdrop-filter: blur(36px) saturate(170%) brightness(1.04);
  -webkit-backdrop-filter: blur(36px) saturate(170%) brightness(1.04);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 22px;
  display: flex;
  flex-direction: column;
  font-family: var(--font-ui);
  box-shadow:
    0 24px 60px rgba(0, 0, 0, 0.55),
    0 1px 0 rgba(255, 255, 255, 0.08) inset;
  opacity: 0;
  transform: translateX(-50%) translateY(12px);
  pointer-events: none;
  transition: opacity 0.22s ease, transform 0.28s cubic-bezier(.22, 1, .36, 1);
  overflow: hidden;
}
#mobile-studio-panel[hidden] { display: none; }
#mobile-studio-panel.open {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
  pointer-events: auto;
}
#mobile-studio-panel .msp-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  padding: 14px 14px 10px 16px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  flex: 0 0 auto;
}
#mobile-studio-panel .msp-title {
  font-size: 14px;
  font-weight: 500;
  color: #fff;
  letter-spacing: 0.02em;
}
#mobile-studio-panel .msp-sub {
  margin-top: 3px;
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.20em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.48);
}
#mobile-studio-panel .msp-close {
  flex: 0 0 auto;
  width: 32px;
  height: 32px;
  border-radius: 8px;
  border: 0;
  background: transparent;
  color: rgba(255, 255, 255, 0.55);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
#mobile-studio-panel .msp-close svg { width: 16px; height: 16px; }
#mobile-studio-panel .msp-close:active { background: rgba(255, 255, 255, 0.06); }
#mobile-studio-panel .msp-body {
  padding: 6px 4px 10px;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
  flex: 1 1 auto;
}
/* The re-parented #usd-layers-panel needs to drop its standalone
   floating chrome (it was originally positioned fixed at top-right
   on its own). Inside our Studio frame it flows like normal content. */
#mobile-studio-panel #usd-layers-panel {
  position: static !important;
  width: 100% !important;
  max-width: none !important;
  max-height: none !important;
  border: 0 !important;
  background: transparent !important;
  box-shadow: none !important;
  border-radius: 0 !important;
  padding: 0 !important;
  margin: 0 !important;
  display: block !important;
}
/* The panel's own header text is redundant â€” our own .msp-head already
   announces "3DGS / USD". Hide just the title, keep the Use My Own
   upload button which is genuinely useful. */
#mobile-studio-panel #usd-layers-panel > header > .title { display: none !important; }
#mobile-studio-panel #usd-layers-panel > header {
  padding: 8px 12px 4px !important;
  border: 0 !important;
  background: transparent !important;
}

/* ============================================================
 * MobileStudioPanel â€” visual refresh (reference: sulmurry_ui_ux
 * "Venuxo" event-space mobile concept). The goal is a luminous,
 * airy glass-morphism panel that reads as a stack of FLOATING
 * TRANSLUCENT CARDS rather than the previous flat-dark rectangle.
 *
 * Design language vs the old panel:
 *   â€¢ Outer container â€” lighter, more translucent glass with heavier
 *     blur + saturation. Border-radius bumped 14 â†’ 28 so it feels
 *     like a pill-stack instead of a window. Soft diffused outer
 *     shadow + a 1-px inner highlight ring on the top edge for the
 *     iOS-26 "etched glass" look.
 *   â€¢ Header â€” drop the divider hairline; the cards below provide
 *     enough visual separation. Title weight up, subtitle goes
 *     softer caps. Close Ã— becomes a soft circular chip.
 *   â€¢ Each .usd-row becomes its OWN floating glass card (white
 *     translucent fill, 1-px hairline, soft shadow). Rows now
 *     stack with breathing room instead of running edge-to-edge.
 *   â€¢ .usd-pill â€” capsule chips with brighter active state + a
 *     subtle white glow. Reads as "tab chips" matching the
 *     Weddings / Concerts / Outdoor row in the reference.
 *   â€¢ .usd-toggle â€” keep the iOS-style switch; lift the "on" state
 *     to pure white + add a soft halo so it pops against the
 *     glass card.
 *   â€¢ .usd-upload â€” promoted from text-link into a tappable pill
 *     so it lines up visually with the other primary affordances
 *     (a small "ghost button" rather than vanishing text).
 *
 * All overrides scoped to #mobile-studio-panel so the embedded-
 * inside-lil-gui desktop experience keeps its compact treatment.
 * ============================================================ */
#mobile-studio-panel {
  /* Lighter glass â€” lets the splat read through softly. Heavier
     blur + saturation gives the colours behind a creamy tint
     instead of muddy dark. */
  background: rgba(26, 28, 34, 0.55) !important;
  backdrop-filter: blur(40px) saturate(170%) brightness(1.05) !important;
  -webkit-backdrop-filter: blur(40px) saturate(170%) brightness(1.05) !important;
  border: 1px solid rgba(255, 255, 255, 0.14) !important;
  border-radius: 26px !important;
  box-shadow:
    0 24px 64px rgba(0, 0, 0, 0.50),
    0 1px 0 rgba(255, 255, 255, 0.10) inset,
    0 0 0 1px rgba(255, 255, 255, 0.04) inset !important;
}
/* Header â€” bigger title, softer divider (dropped), close pill */
#mobile-studio-panel .msp-head {
  padding: 18px 18px 12px 22px !important;
  border-bottom: 0 !important;
}
#mobile-studio-panel .msp-title {
  font-size: 16px !important;
  font-weight: 600 !important;
  letter-spacing: 0 !important;
}
#mobile-studio-panel .msp-sub {
  font-size: 9.5px !important;
  letter-spacing: 0.22em !important;
  color: rgba(255, 255, 255, 0.55) !important;
}
#mobile-studio-panel .msp-close {
  width: 36px !important;
  height: 36px !important;
  border-radius: 999px !important;
  background: rgba(255, 255, 255, 0.06) !important;
  border: 1px solid rgba(255, 255, 255, 0.10) !important;
  color: rgba(255, 255, 255, 0.78) !important;
  transition: background 0.15s ease, transform 0.15s ease !important;
}
#mobile-studio-panel .msp-close:hover {
  background: rgba(255, 255, 255, 0.12) !important;
}
#mobile-studio-panel .msp-close:active {
  transform: scale(0.94) !important;
}
#mobile-studio-panel .msp-body {
  padding: 4px 14px 18px !important;
}

/* The hint strip ("TOGGLE LAYERS Â· STACK FREELY") above the rows â€”
   tighten to a single mono caption that reads as a section eyebrow
   instead of a chunky paragraph. */
#mobile-studio-panel #usd-layers-panel .usd-hint {
  padding: 0 6px 12px !important;
  font-size: 8.5px !important;
  letter-spacing: 0.22em !important;
  color: rgba(255, 255, 255, 0.40) !important;
}

/* Row list â€” vertical stack with cards as children. Remove the
   default 6px padding and use gap so the cards float free. */
#mobile-studio-panel #usd-layers-panel .usd-row-list {
  display: flex !important;
  flex-direction: column !important;
  gap: 10px !important;
  padding: 0 !important;
}

/* AR-COMPACT GRID MODE â€” each layer row uses CSS grid with a
   FIXED 2-column structure so the three slider tracks all share
   the same start AND end X coordinates across rows:
       column 1 (110 px)        column 2 (1 fr, right-aligned)
       â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”¬â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
   r1: â”‚ [toggle] Name         â”‚            [pill] [pill]     â”‚
   r2: â”‚            â”â”â”â”â”â”â”â”â—â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â” slider full   â”‚
       â””â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”´â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”˜
   This matches the user's "ä¸‰ä¸ªæ»‘åŠ¨æ¡éƒ½è¦å¯¹é½" requirement.
   `.usd-row-sub` uses `display: contents` so its children
   participate in the .usd-row grid directly (no nested layout
   container blocking the alignment). Tighter padding (8/2 px
   vs the old 10/2 + 10/16) shrinks each row by ~30 %, satisfying
   the "æ›´ç´§å‡‘" ask without sacrificing tap target size. */
#mobile-studio-panel #usd-layers-panel .usd-row {
  background: transparent !important;
  border: 0 !important;
  border-radius: 0 !important;
  padding: 8px 2px !important;
  box-shadow: none !important;
  display: grid !important;
  grid-template-columns: 110px 1fr !important;
  grid-template-rows: auto auto !important;
  column-gap: 12px !important;
  row-gap: 4px !important;
  align-items: center !important;
  transition: opacity 0.18s ease !important;
}
/* Thin separator BETWEEN rows so the rhythm reads as a list, not
   floating cards. First row stays flush with the panel padding. */
#mobile-studio-panel #usd-layers-panel .usd-row + .usd-row {
  border-top: 1px solid rgba(255, 255, 255, 0.06) !important;
}
/* "Off" row â€” softer hue across the whole row so state reads at a
   glance without needing to find the toggle position. */
#mobile-studio-panel #usd-layers-panel .usd-row.hidden-row {
  opacity: 0.55 !important;
}

/* Toggle + name cluster â€” pinned to grid column 1 / row 1, fixed
   width so the divider between toggle-area and controls is at the
   SAME X for every row (this is what makes the sliders below
   line up perfectly across rows). */
#mobile-studio-panel #usd-layers-panel .usd-row-main {
  grid-column: 1 !important;
  grid-row: 1 !important;
  display: flex !important;
  flex-direction: row !important;
  align-items: center !important;
  gap: 10px !important;
}
/* `.usd-row-sub` becomes a layout-transparent wrapper â€” its
   children (pills + size) flow into the parent grid directly,
   so we can place each one in an explicit grid cell below. */
#mobile-studio-panel #usd-layers-panel .usd-row-sub {
  display: contents !important;
}
#mobile-studio-panel #usd-layers-panel .usd-name {
  font-size: 13px !important;
  font-weight: 500 !important;
  color: #fff !important;
}
/* Technical badge ("PointInstancer Â· Plane" etc.) â€” hidden in the
   compact view. Re-discoverable on the desktop Advanced panel; on
   mobile the subform pill names already convey "what you're
   looking at" without the schema string fighting for line width. */
#mobile-studio-panel #usd-layers-panel .usd-badge {
  display: none !important;
}

/* Pills cluster â€” placed in grid column 2, row 1, justified to
   the END so the chips hug the right edge of the panel. The
   grid's fixed column 1 + this right-justify means the chips
   for every row land in a consistent vertical lane. */
#mobile-studio-panel #usd-layers-panel .usd-pill-group {
  grid-column: 2 !important;
  grid-row: 1 !important;
  justify-self: end !important;
  display: flex !important;
  flex-direction: row !important;
  align-items: center !important;
}
/* Size slider â€” spans both columns on the SECOND grid row, so all
   three sliders share the same left + right edge. This is what
   the user asked for: "æ»‘åŠ¨æ¡ä¸‰ä¸ªéƒ½è¦å¯¹é½". The slider sits flush
   to the row's full width regardless of how long each layer's
   name happens to be. Compact treatment matches the previous
   inline-flex slider (slim track, 14-px thumb, mono caption +
   value). When the slider is hidden (Splat in Gaussian mode), the
   grid row simply collapses to 0. */
#mobile-studio-panel #usd-layers-panel .usd-size {
  display: flex !important;
  grid-column: 1 / -1 !important;
  grid-row: 2 !important;
  align-items: center !important;
  gap: 10px !important;
  margin: 2px 0 0 0 !important;
  padding: 0 !important;
}
#mobile-studio-panel #usd-layers-panel .usd-size-label {
  font-family: var(--font-mono) !important;
  font-size: 9px !important;
  letter-spacing: 0.18em !important;
  color: rgba(255, 255, 255, 0.45) !important;
  text-transform: uppercase !important;
  flex: 0 0 auto !important;
  white-space: nowrap !important;
}
#mobile-studio-panel #usd-layers-panel .usd-size input[type="range"] {
  flex: 1 1 auto !important;
  height: 18px !important;
  -webkit-appearance: none !important;
  appearance: none !important;
  background: transparent !important;
}
#mobile-studio-panel #usd-layers-panel .usd-size input[type="range"]::-webkit-slider-runnable-track {
  height: 3px !important;
  border-radius: 2px !important;
  background: rgba(255, 255, 255, 0.16) !important;
}
#mobile-studio-panel #usd-layers-panel .usd-size input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none !important;
  width: 14px !important;
  height: 14px !important;
  margin-top: -5.5px !important;
  border-radius: 50% !important;
  background: #fff !important;
  border: 0 !important;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4) !important;
  cursor: grab !important;
}
#mobile-studio-panel #usd-layers-panel .usd-size input[type="range"]::-moz-range-track {
  height: 3px !important;
  border-radius: 2px !important;
  background: rgba(255, 255, 255, 0.16) !important;
}
#mobile-studio-panel #usd-layers-panel .usd-size input[type="range"]::-moz-range-thumb {
  width: 14px !important;
  height: 14px !important;
  border-radius: 50% !important;
  background: #fff !important;
  border: 0 !important;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4) !important;
  cursor: grab !important;
}
#mobile-studio-panel #usd-layers-panel .usd-size-val {
  font-family: var(--font-mono) !important;
  font-size: 10px !important;
  font-variant-numeric: tabular-nums !important;
  color: rgba(255, 255, 255, 0.92) !important;
  flex: 0 0 auto !important;
  min-width: 42px !important;
  text-align: right !important;
}

/* Subform pill chips â€” capsule, soft fill, brighter active state
   with a tiny white glow halo. Tightened down a notch for the
   compact one-line layout (was 6/14, now 4/11) so two pills fit
   comfortably on the right side of a 320-ish-px-wide row. */
#mobile-studio-panel #usd-layers-panel .usd-pill-group {
  gap: 4px !important;
}
#mobile-studio-panel #usd-layers-panel .usd-pill {
  padding: 5px 11px !important;
  border-radius: 999px !important;
  background: rgba(255, 255, 255, 0.05) !important;
  border: 1px solid rgba(255, 255, 255, 0.10) !important;
  color: rgba(255, 255, 255, 0.78) !important;
  font-size: 10.5px !important;
  letter-spacing: 0.02em !important;
  transition: background 0.15s ease, color 0.15s ease, box-shadow 0.18s ease !important;
}
#mobile-studio-panel #usd-layers-panel .usd-pill:hover {
  background: rgba(255, 255, 255, 0.10) !important;
  color: #fff !important;
}
#mobile-studio-panel #usd-layers-panel .usd-pill.active {
  background: rgba(255, 255, 255, 0.20) !important;
  border-color: rgba(255, 255, 255, 0.30) !important;
  color: #fff !important;
  box-shadow: 0 0 14px rgba(255, 255, 255, 0.12) !important;
}

/* iOS toggle â€” sized down a notch to match the compact one-line
   row layout. Bright "on" state keeps the halo so the toggle still
   pops against the panel's glass. */
#mobile-studio-panel #usd-layers-panel .usd-toggle {
  width: 38px !important;
  height: 22px !important;
  border-radius: 999px !important;
  background: rgba(255, 255, 255, 0.10) !important;
  border: 1px solid rgba(255, 255, 255, 0.10) !important;
  transition: background 0.2s ease, box-shadow 0.2s ease !important;
}
#mobile-studio-panel #usd-layers-panel .usd-toggle.on {
  background: rgba(255, 255, 255, 0.92) !important;
  border-color: rgba(255, 255, 255, 0.95) !important;
  box-shadow: 0 0 16px rgba(255, 255, 255, 0.18) !important;
}
#mobile-studio-panel #usd-layers-panel .usd-toggle .usd-toggle-knob {
  width: 16px !important;
  height: 16px !important;
  background: #fff !important;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35) !important;
}
#mobile-studio-panel #usd-layers-panel .usd-toggle.on .usd-toggle-knob {
  background: #0e1014 !important;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.20) !important;
}

/* Size slider â€” pull the label / value into a single tidy row
   that fits inside the card's padding. */
#mobile-studio-panel #usd-layers-panel .usd-size {
  margin-top: 8px !important;
  padding: 0 !important;
}
#mobile-studio-panel #usd-layers-panel .usd-size-label {
  font-size: 9px !important;
  letter-spacing: 0.18em !important;
  color: rgba(255, 255, 255, 0.45) !important;
}
#mobile-studio-panel #usd-layers-panel .usd-size-val {
  font-size: 10.5px !important;
  color: rgba(255, 255, 255, 0.85) !important;
}

/* "Use My Own" â€” promoted from buried text-link into a soft
   pill-button so it sits at the same affordance level as the
   per-row toggles. Still discreet (low-key border + transparent
   fill), but tappable instead of needing tiny-text aim. */
#mobile-studio-panel #usd-layers-panel .usd-upload {
  display: inline-flex !important;
  align-items: center !important;
  justify-content: center !important;
  margin: 16px auto 4px !important;
  padding: 10px 22px !important;
  background: rgba(255, 255, 255, 0.06) !important;
  border: 1px solid rgba(255, 255, 255, 0.12) !important;
  border-radius: 999px !important;
  color: rgba(255, 255, 255, 0.82) !important;
  font-size: 10px !important;
  letter-spacing: 0.20em !important;
  transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease !important;
}
#mobile-studio-panel #usd-layers-panel .usd-upload:hover {
  background: rgba(255, 255, 255, 0.12) !important;
  color: #fff !important;
}
#mobile-studio-panel #usd-layers-panel .usd-upload:active {
  transform: scale(0.97) !important;
}

/* Phone-landscape asset card: viewport is wide but only ~430 px
   tall, while the floating card's natural size is tuned for desktop
   / iPad height â€” the card ate 70 % of the viewport. Proportionally
   scale it down using `zoom` (NOT `transform: scale`) because zoom
   updates the layout coordinate system, so the JS anchor logic in
   asset-hover.js (which reads `offsetWidth` / `getBoundingClientRect`
   to position the card next to its dot) still works correctly.
   Targets only short landscape viewports â€” iPad in landscape (height
   â‰¥ 700 px) keeps the full-size card.

   Replaces an earlier `zoom: 0.72` hack: zoom in Chrome doesn't update
   offsetWidth/offsetHeight consistently across versions, so the JS
   clamping in asset-hover.js read the pre-zoom size and let the card
   overshoot the viewport. Explicit smaller dimensions keep the clamp
   math honest, and the right-side gutter accounts for the rotated
   mobile bottombar pill anchored to the right edge. */
@media (orientation: landscape) and (max-height: 520px) {
  #asset-hover-card {
    width: min(420px, 58vw);
    max-height: calc(100vh - 24px);
    /* Right margin reserves room for the column-mode bottombar pill
       (â‰ˆ 76 px wide) + a small breathing gap. */
    margin-right: 88px;
  }
}

/* ============================================================
 * Phone LANDSCAPE â€” short viewport tuning. Since the recent restyle
 * the BASE #mobile-sheet rule already lays the panel out as a
 * centred floating card (translateX(-50%), max-width 480, all-four
 * rounded corners, frosted glass) so the old landscape override
 * that duplicated most of that work is gone. What's left is the
 * one thing that genuinely needs to differ in landscape: a smaller
 * max-height + a slightly tighter horizontal cap so the card
 * doesn't sprawl across a short / wide viewport. Wider phones
 * (Plus / Pro Max landscape, > 768 px) drop body.mobile entirely
 * via JS re-detection and get the desktop / iPad layout. */
@media (orientation: landscape) and (max-width: 1024px) {
  body.mobile #mobile-sheet {
    max-width: 560px;
    max-height: 60vh;
  }
  /* JS drag handler always composes translateX(-50%) now (every
     orientation centres horizontally), so no landscape-specific
     transform override is needed. */
}

/* ---- Phone landscape: sheets and studio panel anchor LEFT, capped ----
 * Default mobile layout centres every sheet horizontally (translateX
 * -50%). On landscape phones the bottombar pill is already at the right
 * edge, so a centre-aligned sheet leaves a wedge of empty splat scene
 * on the LEFT and visually competes with the dock on the RIGHT. Anchor
 * sheets to the left edge instead: dock-right, sheet-left, splat in
 * the middle â€” three clear zones, no overlap. Cap width so the sheet
 * reads as an intentional drawer, not a stretched-too-wide slab.
 *
 * Also tightens vertical rhythm inside the sheets (header padding,
 * section padding) since landscape viewports are short (~400 px). */
@media (orientation: landscape) and (max-height: 520px) {
  /* Anchor sheets TOP-LEFT instead of bottom-centred. Reasoning:
     - The bottombar pill is now on the RIGHT edge, so a bottom-anchored
       sheet no longer needs to clear it; bottom-anchored just pushes the
       top of the sheet UP past the URL bar / notch on short viewports.
     - iOS Safari in landscape eats ~50 px at the top with the URL bar
       and another ~21 px with the notch / dynamic island
       (env(safe-area-inset-top)). Anchoring to TOP + safe-area-inset
       guarantees the sheet header is always visible no matter how the
       browser chrome resizes.
     - dvh (dynamic viewport height) responds correctly to Safari URL-bar
       collapse/expand; plain vh doesn't, and would over-size the sheet
       when the URL bar is visible.
     - max-width tightened 480 â†’ 400 so the card occupies â‰¤ 50 % of a
       typical 800 px landscape phone. "User interaction first, don't
       block most of the screen."
     - Slide-in changes from bottomâ†’up to leftâ†’right; matches the
       left-anchor mental model. */
  body.mobile #mobile-sheet {
    left:   calc(14px + env(safe-area-inset-left, 0px));
    right:  auto;
    top:    calc(14px + env(safe-area-inset-top, 0px));
    bottom: auto;
    max-width:  400px;
    max-height: calc(100dvh - 28px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
    transform: translateX(calc(-100% - 28px)) translateY(0);
  }
  body.mobile #mobile-sheet.open      { transform: translateX(0) translateY(0); }
  body.mobile #mobile-sheet .ms-section { padding: 8px 14px; }
  body.mobile #mobile-sheet .ms-row     { padding: 6px 0; }

  #mobile-studio-panel {
    left:   calc(14px + env(safe-area-inset-left, 0px));
    right:  auto;
    top:    calc(14px + env(safe-area-inset-top, 0px));
    bottom: auto;
    max-width:  400px;
    max-height: calc(100dvh - 28px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
    transform: translateX(calc(-100% - 28px));
  }
  #mobile-studio-panel.open {
    transform: translateX(0);
  }
  #mobile-studio-panel .msp-head { padding: 10px 14px; }
  #mobile-studio-panel .msp-body { padding: 4px 14px 12px; }

  /* Asset hover card â€” left-anchored + safe-area-top, capped to the
     usable height between safe areas. */
  #asset-hover-card {
    left:   calc(14px + env(safe-area-inset-left, 0px)) !important;
    top:    calc(14px + env(safe-area-inset-top, 0px))  !important;
    max-height: calc(100dvh - 28px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)) !important;
    width:      min(400px, 56vw);
  }
}

/* ---- Phone landscape: anchor #mobile-bottombar to the right edge ----
 * The bottom-edge pill eats vertical real estate that's already scarce
 * in landscape (most phones are ~400 px tall), and reaching back to the
 * bottom is awkward when the device is held horizontally. Anchor the
 * pill to the right edge as a vertical strip â€” same buttons, just laid
 * out as a column. Targets short landscape viewports only; tablets and
 * wider phones (Plus / Pro Max landscape) drop body.mobile via JS. */
@media (orientation: landscape) and (max-height: 520px) {
  body.mobile #mobile-bottombar {
    flex-direction: column;
    height: auto;
    max-height: 84vh;
    padding: 8px 6px;
    left: auto;
    right: calc(10px + env(safe-area-inset-right, 0px));
    bottom: auto;
    top: 50%;
    transform: translateY(-50%);
    border-radius: 36px;
    /* The pill widens slightly in column mode so labels still fit. */
    min-width: 64px;
  }
  body.mobile #mobile-bottombar button {
    /* Tighter vertical padding so the column fits five buttons
       comfortably on a ~360 px landscape viewport. */
    padding: 6px 8px;
    min-width: 0;
  }
}

/* ============================================================
 * Responsive â€” phone (<768px) and tablet (768-1024px) tuning
 * ============================================================ */
@media (max-width: 767px) {
  /* Pipeline drawer â€” full width, edge-to-edge */
  #tech-spec .ts-panel {
    width: 100vw !important;
    max-width: 100vw !important;
  }
  /* Credits — near-full width, capped height with internal scroll.
     Reserve room for the floating mobile bottombar (64px capsule that
     sits 14px above the viewport bottom plus env(safe-area-inset-bottom)
     on home-indicator phones, total ~78px on iPhone SE / 13 mini and
     ~112px on iPhone 14 Pro etc). Because the panel is centered, every
     pixel cut from max-height removes equally from top and bottom, so
     we subtract roughly twice the bar's vertical footprint plus a
     breathing buffer. The previous rule (100vh - 96px) let the panel's
     lower edge slip UNDER the bottombar capsule on the SE / 13 mini
     viewport (375x667), hiding the last list rows behind the bar.
     The new math leaves ~22px of clearance regardless of safe-area. */
  #credits {
    width: calc(100vw - 24px);
    max-width: calc(100vw - 24px);
    max-height: calc(100vh - 200px - (env(safe-area-inset-bottom, 0px) * 2));
  }
  /* Loading splash text sizing */
  .loading-splash .ld-title { font-size: clamp(40px, 12vw, 64px) !important; }
  .loading-splash .ld-desc  { font-size: 13px !important; }
  .loading-splash .ld-desc-fine { font-size: 11px !important; }
  /* Intro overlay copy â€” reduce so it fits a phone screen */
  #intro-overlay .io-hero-title { font-size: clamp(34px, 11vw, 56px); }
  #intro-overlay .io-hero-sub   { font-size: 10px; letter-spacing: 0.22em; }
  #intro-overlay .io-phase      { left: 5vw; bottom: 10vh; max-width: 86vw; }
  #intro-overlay .io-phase-text { font-size: clamp(18px, 5vw, 24px); }
  /* lil-gui â€” move to bottom and limit height; tap targets bigger */
  .lil-gui.root {
    width: calc(100vw - 16px) !important;
    max-width: 360px;
    right: 8px !important;
    bottom: 8px !important;
    top: auto !important;
    max-height: 60vh !important;
  }
  /* Scene panel + viewpoint sidebar â€” compact */
  #left-stack { max-width: 200px; }
  /* Fallback bottom-sheet for the standalone case â€” only applies when
   * UsdLayers is NOT embedded inside lil-gui. Once embedded the panel
   * rides with lil-gui (which goes bottom-sheet on phones via its
   * own .lil-gui.root rule above). */
  #usd-layers-panel:not(.usd-embedded) {
    position: fixed;
    top: auto !important;
    left: 8px !important;
    right: 8px !important;
    bottom: 8px;
    width: auto !important;
    max-height: 50vh !important;
    z-index: 25;
  }
  /* Onboarding pointers â€” narrower arrow on phone so the tip fits next
     to small targets like a 1/5-width bottom-bar tab. Positions are
     handled by JS (see onboarding-pointers.js), so no more hardcoded
     [data-i=â€¦] overrides here. */
  #onboarding-pointers .op-arrow      { width: 60px; height: 30px; }
  #onboarding-pointers .op-arrow-wrap { transform-origin: center; }
  /* Asset hover card â€” full-width sheet */
  #asset-hover-card {
    width: calc(100vw - 16px) !important;
    max-width: calc(100vw - 16px) !important;
    left: 8px !important;
    right: 8px !important;
    transform: none !important;
  }
}

@media (min-width: 768px) and (max-width: 1024px) {
  /* iPad / large tablet â€” slightly tighter than desktop */
  #tech-spec .ts-panel {
    width: 70vw !important;
    max-width: 600px;
  }
  #credits {
    width: min(480px, calc(100vw - 32px));
  }
  /* Standalone fallback only â€” embedded UsdLayers rides with lil-gui. */
  #usd-layers-panel:not(.usd-embedded) {
    width: 260px !important;
    max-height: 360px !important;
  }
  /* Annotation card narrower so it doesn't sit on top of the right rail. */
  #usd-annotations { width: min(320px, calc(100vw - 320px)); right: 296px; }
}

/* Touch â€” bigger tap targets everywhere */
body.touch .ts-hotspot-toggle,
body.touch .vt-save-btn,
body.touch .cr-close,
body.touch .ts-close { min-height: 32px; padding: 6px 12px; }
body.touch #app .annotation { width: 32px; height: 32px; font-size: 13px; }
body.touch .asset-hotspot .ahot-label { font-size: 12px; }
/* Suppress the floating hotspot labels while an asset card is pinned â€”
   the card title already names the asset so the extra "Gazebo" /
   "Daffodil" pills near each dot become redundant chrome that fights
   the card for screen space. Dots themselves remain visible so the
   user can still see WHERE assets sit in 3D. */
body.asset-card-pinned .asset-hotspot .ahot-label { opacity: 0; }
/* When hand tracking is active, hide the floating asset labels â€”
   they compete with the hand cursor for the user's visual attention
   and make the scene feel chatty during gesture interaction. The
   dots themselves stay visible so users still see WHERE assets are;
   only the text pills next to each dot fade out. The transition
   gives a soft handoff when the user toggles tracking on/off. */
body:has(#hand-toggle.active) .asset-hotspot .ahot-label {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease;
}
/* Touch hit-area + drag isolation. The 44 Ã— 44 hit halo itself lives
   on the universal .asset-hotspot::before rule (was scoped to
   body.touch before the HIG audit â€” the wider hit zone helps every
   device including mouse users with motor disabilities). All that
   stays touch-specific is the `touch-action: none` declaration,
   which prevents the browser's default tap-to-pan behaviour from
   stealing finger events meant for the asset hotspot. */
body.touch .asset-hotspot { touch-action: none; }

/* ============================================================
   Accessibility â€” reduced motion (PM-8)

   Visitors who've set their OS preference to "Reduce motion"
   shouldn't experience: long camera fly-tos, animated splash
   particles, sliding panels, lengthy entrance choreography.
   We damp the durations to near-zero and disable looping /
   bouncy animations entirely.

   The biggest motion source â€” the FBX cinematic auto-play â€”
   is gated in main.js (matchMedia "prefers-reduced-motion:
   reduce") so we don't even spin up the mixer. The CSS below
   handles the smaller UI motion: panel slides, hover bounces,
   choreographed ui-ready entrances, hotspot pulses.
   ============================================================ */
/* ============================================================
   Cinematic intro lockout

   While the auto-played camera move is running we don't want the user
   to accidentally trigger Scan FX (click on splat), fly to a hotspot
   (click on asset dot), jump to a numbered viewpoint (click on the
   annotation dot), or activate the brush. Body.intro-playing is set/
   cleared in main.js around camera-move state transitions; here we
   just neutralise pointer-events on the relevant scene surfaces.

   The right-rail GUI, Scene panel, Viewpoints sidebar, About pill â€”
   these stay clickable because they're how the user can ESCAPE the
   intro (e.g. by pressing Stop in lil-gui). Only the IN-VIEWPORT
   click surfaces get disabled.
   ============================================================ */
body.intro-playing #viewport {
  pointer-events: none;
}
body.intro-playing .asset-hotspot,
body.intro-playing .annotation,
body.intro-playing #annotation-layer {
  pointer-events: none;
}

/* ============================================================
   Fatal error card (PM-13)

   Shown when:
   - WebGL 2 is unavailable (pre-flight check at boot)
   - Splat asset failed to load (404, network error, corruption)

   Sits above the loading splash with a centered card that can be
   read and acted on (Try again â†’ reload). Designed to feel like
   part of the editorial design language, not a stock browser
   error message: same glassmorphism, same typography, same
   one-clear-action affordance as the rest of the app.
   ============================================================ */
.fatal-error {
  position: fixed;
  inset: 0;
  z-index: 9999;
  background: rgba(11, 15, 20, 0.96);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  font-family: var(--font-ui, system-ui);
  color: var(--text, #fff);
}
.fatal-error .fe-card {
  max-width: 520px;
  width: 100%;
  padding: 28px 30px 26px;
  background: var(--panel, rgba(16, 20, 26, 0.92));
  border: 1px solid var(--hair-strong, rgba(255, 255, 255, 0.12));
  border-radius: 14px;
  box-shadow: var(--shadow-soft, 0 12px 40px rgba(0, 0, 0, 0.6));
  text-align: center;
}
.fatal-error .fe-title {
  font-size: 18px;
  font-weight: 300;
  letter-spacing: -0.015em;
  margin-bottom: 12px;
  color: var(--text, #fff);
}
.fatal-error .fe-body {
  font-size: 13px;
  line-height: 1.55;
  color: var(--text-mid, rgba(255, 255, 255, 0.7));
  margin-bottom: 22px;
}
.fatal-error .fe-retry {
  font: inherit;
  font-size: 11px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text, #fff);
  background: transparent;
  border: 1px solid var(--hair-strong, rgba(255, 255, 255, 0.18));
  padding: 9px 22px;
  border-radius: 999px;
  cursor: pointer;
  transition: background 0.18s ease, border-color 0.18s ease;
}
.fatal-error .fe-retry:hover {
  background: rgba(255, 255, 255, 0.10);
  border-color: rgba(255, 255, 255, 0.32);
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

/* ============================================================
   Mobile touch-action audit (PM-7)

   Goal: every scrollable panel scrolls cleanly with one finger;
   every gesture-capturing surface doesn't fight the browser's
   default vertical pan. The canvas already gets `touch-action:
   none` (it captures orbit / pan / zoom gestures whole) but the
   floating overlay PANELS (Tech Spec drawer, Credits, Asset Hover
   card, mobile bottom-sheet) are content surfaces â€” they need
   the OPPOSITE: allow vertical-pan scrolling, smooth iOS momentum.

   Applied surfaces: anything with overflow-y:auto that the user
   reads with their thumb. Excludes the canvas explicitly via
   :not(#viewport) so this doesn't clobber the orbit handler.
   ============================================================ */
body.touch #tech-spec,
body.touch #credits,
body.touch #asset-hover-card,
body.touch #mobile-sheet,
body.touch #scene-panel .layer-list,
body.touch #sidebar #viewpoint-list,
body.touch .ah-card,
body.touch .ts-content {
  touch-action: pan-y;
  -webkit-overflow-scrolling: touch;   /* iOS momentum scrolling */
  overscroll-behavior: contain;        /* don't bounce the page itself */
}
/* The lil-gui panel on touch â€” same treatment. Its inner .children
   contain knob rows AND scrollable folders, so we want vertical scroll
   to work without the user accidentally dragging the panel itself. */
body.touch .lil-gui.root,
body.touch .lil-gui.root .children {
  touch-action: pan-y;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
}

/* ============================================================
 * About â€” project-narrative panel + prominent floating trigger.
 *
 * Separate surface from Credits (Credits stays as team/thanks/
 * software). About is the "what is this project actually about?"
 * surface â€” visible-by-default trigger at the top centre of the
 * viewport, designed to be reached in the first few seconds.
 * ============================================================ */

/* --- Trigger pill (top centre, more prominent than the bottom-toolbar
       About link). Bright outline ring on idle, subtle glow on hover,
       compressed slightly when active so the click reads as tactile. */
#about-trigger {
  position: fixed;
  top: calc(14px + env(safe-area-inset-top, 0px));
  left: 50%;
  transform: translateX(-50%);
  z-index: 1490;            /* above lil-gui (1001) and asset-card (1200) */
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 16px 9px 14px;
  background: rgba(18, 18, 22, 0.68);
  border: 1px solid rgba(255, 255, 255, 0.22);
  border-radius: 999px;
  color: rgba(255, 255, 255, 0.94);
  font-family: var(--font-ui);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.04em;
  cursor: pointer;
  backdrop-filter: blur(28px) saturate(160%);
  -webkit-backdrop-filter: blur(28px) saturate(160%);
  box-shadow:
    0 6px 22px rgba(0, 0, 0, 0.42),
    inset 0 1px 0 rgba(255, 255, 255, 0.10);
  transition: background 0.18s ease, border-color 0.18s ease,
              transform 0.14s ease, box-shadow 0.18s ease;
  /* Soft attention pulse for the first ~8 seconds after load â€” fades to
     idle automatically. Lets a casual visitor see the entry point
     without it being permanent visual noise. */
  animation: ab-trig-pulse 2.2s ease-in-out 0.8s 3 normal both;
}
#about-trigger:hover {
  background: rgba(28, 28, 34, 0.78);
  border-color: rgba(255, 255, 255, 0.36);
  box-shadow:
    0 10px 28px rgba(0, 0, 0, 0.50),
    inset 0 1px 0 rgba(255, 255, 255, 0.14);
}
#about-trigger:active { transform: translateX(-50%) scale(0.97); }
#about-trigger.active {
  background: rgba(255, 255, 255, 0.10);
  border-color: rgba(255, 255, 255, 0.40);
  animation: none;
}
#about-trigger .ab-trig-dot {
  width: 7px; height: 7px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 8px rgba(255, 255, 255, 0.55);
}
#about-trigger .ab-trig-arrow {
  font-size: 11px;
  color: rgba(255, 255, 255, 0.72);
  transition: transform 0.18s ease;
}
#about-trigger:hover .ab-trig-arrow,
#about-trigger.active .ab-trig-arrow { transform: translateX(2px); }

/* The pulse: subtle outer ring breathing â€” emphatic enough to draw
   the eye on first load, gentle enough to not feel like an ad. Auto-
   retires after 3 iterations (~6.6 s). */
@keyframes ab-trig-pulse {
  0%   { box-shadow: 0 6px 22px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.10), 0 0 0 0 rgba(255, 255, 255, 0.32); }
  60%  { box-shadow: 0 6px 22px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.10), 0 0 0 14px rgba(255, 255, 255, 0); }
  100% { box-shadow: 0 6px 22px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.10), 0 0 0 0 rgba(255, 255, 255, 0); }
}

/* While the cinematic auto-play is running, hide the trigger so it
   doesn't compete with the intro overlay. Re-appears once the intro
   finishes (body.intro-playing is removed in main.js). */
body.intro-playing #about-trigger { opacity: 0; pointer-events: none; }

/* While hand tracking is active, hide the trigger pill: the gesture HUD
   (#hand-gesture-hud) is fixed at top-center too and at z-index 1700 it
   covers the trigger (1490). Beyond the visual stacking, the conflict
   is informational — both elements compete for the head-up slot but
   serve different modes: About is an onboarding CTA for new visitors,
   the gesture HUD is real-time feedback for someone already deep in
   gesture interaction. Once the user has opted into hand tracking they
   are by definition past onboarding, so the trigger is noise during
   that session. Pill returns the moment hand tracking is toggled off.
   Mirrors the existing body.intro-playing pattern above. */
body:has(#hand-toggle.active) #about-trigger {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.22s ease;
}

/* When the About panel is open, hide the trigger pill: the panel's own
   header ("ABOUT THIS PROJECT") sits ~8 px below the trigger ("About
   this project") on phone portrait, and the near-identical text in
   nearly the same place reads as two overlapping headers. Hiding the
   trigger while the panel is open lets the panel's header own the
   identity slot. Trigger fades back in when the panel closes. */
body:has(#about-panel.show) #about-trigger {
  opacity: 0;
  pointer-events: none;
}

/* Also dim the mobile bottombar while the About panel is open so its
   five tab labels don't compete with the panel's bottom CTAs. The bar
   stays clickable (so the user can navigate to Tour / Effects / etc.
   directly without closing About first) but recedes visually. */
body:has(#about-panel.show) #mobile-bottombar {
  opacity: 0.5;
  filter: saturate(0.6);
  transition: opacity 0.22s ease, filter 0.22s ease;
}

/* --- Panel â€” floats centered, fades + scales in. */
#about-panel {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) scale(0.96);
  z-index: 1495;            /* just below mobile-bottombar (1500) */
  width: min(560px, 92vw);
  max-height: calc(100dvh - 80px);
  background: var(--panel-strong);
  backdrop-filter: blur(32px) saturate(160%);
  -webkit-backdrop-filter: blur(32px) saturate(160%);
  border: 1px solid var(--hair);
  border-radius: var(--radius);
  box-shadow: var(--shadow-soft);
  color: var(--text);
  font-family: var(--font-ui);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.22s ease, transform 0.22s cubic-bezier(.22, 1, .36, 1);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
#about-panel[hidden] { display: none; }
#about-panel.show {
  opacity: 1;
  pointer-events: auto;
  transform: translate(-50%, -50%) scale(1);
}

#about-panel .ab-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 18px 12px;
  border-bottom: 1px solid var(--hair);
}
#about-panel .ab-title {
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.20em;
  text-transform: uppercase;
  color: var(--text-mid);
}
#about-panel .ab-close {
  background: transparent;
  border: 0;
  box-sizing: border-box;
  flex-shrink: 0;
  color: var(--text-dim);
  font-size: 22px;
  font-family: var(--font-ui);
  line-height: 1;
  cursor: pointer;
  width: 28px; height: 28px;
  /* Explicit padding:0 to override the UA <button> default (1px 6px),
     same fix as the other close buttons in the project. */
  padding: 0;
  border-radius: 6px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 0.12s, color 0.12s;
}
#about-panel .ab-close:hover { background: var(--hover); color: var(--text); }

#about-panel .ab-body {
  flex: 1;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.22) transparent;
}
#about-panel .ab-body::-webkit-scrollbar { width: 6px; }
#about-panel .ab-body::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.20);
  border-radius: 3px;
}


/* Poster Mode CSS moved to https://github.com/omolism/SplatGarden-Poster
   (lives entirely in that repo now — was ~510 lines of HUD layout +
    tuner positioning + toggle pill + mode-aware Studio chrome hides). */


#about-panel .ab-hero {
  padding: 22px 22px 14px;
  border-bottom: 1px solid var(--hair);
}
#about-panel .ab-eyebrow {
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--text-mid);
  margin-bottom: 8px;
}
#about-panel .ab-name {
  margin: 0 0 10px;
  font-size: clamp(28px, 4.5vw, 38px);
  font-weight: 300;
  letter-spacing: -0.02em;
  color: var(--text);
  line-height: 1.05;
}
#about-panel .ab-blurb {
  margin: 0;
  font-size: 13px;
  line-height: 1.55;
  color: rgba(255, 255, 255, 0.78);
}
#about-panel .ab-blurb strong {
  color: var(--text);
  font-weight: 500;
}
#about-panel .ab-blurb em {
  font-style: italic;
  color: rgba(255, 255, 255, 0.92);
}

#about-panel .ab-pillars-sec {
  padding: 16px 22px 18px;
  border-bottom: 1px solid var(--hair);
}
#about-panel .ab-sec-title {
  font-family: var(--font-ui);
  font-size: 9px;
  font-weight: 500;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 12px;
}
#about-panel .ab-pillars {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
#about-panel .ab-pillar {
  display: grid;
  grid-template-columns: 100px 1fr;
  gap: 12px;
  align-items: start;
  font-size: 12px;
  line-height: 1.45;
}
#about-panel .ab-pillar-tag {
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.92);
  padding-top: 2px;
}
#about-panel .ab-pillar-line {
  color: rgba(255, 255, 255, 0.72);
}

#about-panel .ab-cta-sec {
  /* Now sits OUTSIDE .ab-body so it pins to the bottom of the flex
     column panel â€” content above scrolls, CTAs always visible. The
     top border replaces the section divider that used to live on
     .ab-pillars-sec's bottom edge; opaque-ish background ensures the
     scroll content underneath doesn't bleed through if a scrollbar
     overlay paints behind it. */
  padding: 14px 22px 18px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  flex-shrink: 0;
  border-top: 1px solid var(--hair);
  background: var(--panel-strong);
}
#about-panel .ab-cta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 11px 14px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--hair);
  border-radius: 10px;
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.02em;
  cursor: pointer;
  text-decoration: none;
  transition: background 0.14s ease, border-color 0.14s ease;
}
#about-panel .ab-cta:hover {
  background: rgba(255, 255, 255, 0.08);
  border-color: rgba(255, 255, 255, 0.22);
}
#about-panel .ab-cta-arrow {
  font-size: 12px;
  color: var(--text-mid);
  transition: transform 0.18s ease;
}
#about-panel .ab-cta:hover .ab-cta-arrow { transform: translateX(3px); }

/* --- Mobile + landscape tightening ----------------------------------- */

@media (max-width: 767px) {
  #about-trigger {
    font-size: 11px;
    padding: 8px 14px 8px 12px;
    /* Smaller pulse ring on mobile so it doesn't bleed into the URL bar. */
  }
  #about-trigger .ab-trig-label { letter-spacing: 0.02em; }

  /* Anchor the panel to the TOP, directly under the trigger pill, rather
     than centring it. On short phones (iPhone SE 568 / 13 Mini 667), a
     centred panel with max-height: 100dvh âˆ’ 60 âˆ’ safe-areas grew tall
     enough that its bottom edge bled into the floating #mobile-bottombar
     pill at the bottom (78 px tall + 14 px offset + safe-area-bottom).
     Top-anchoring + capping the height to leave explicit room for the
     bottombar fixes the overlap on small phones AND visually connects
     the panel to its trigger (the panel reads as "expanding out of the
     pill" rather than teleporting to centre).

     Math:
       top offset    = 58 + safe-top   (clears trigger pill, ~40 tall at 14+safe-top)
       bottom budget = 92 + safe-bot   (bottombar 64 + 14 offset + 14 gap)
     max-height = 100dvh - top offset - bottom budget. */
  #about-panel {
    top:    calc(58px + env(safe-area-inset-top, 0px));
    left:   12px;
    right:  12px;
    bottom: auto;
    width:  auto;
    transform: translate(0, 0) scale(0.96);
    /* Cap to whichever is SMALLER: the geometric calc that clears the
       bottombar, OR ~62dvh. The 62dvh cap is the one that matters on
       short phones (iPhone SE / 13 Mini class) â€” without it the panel
       was filling ~74 % of the viewport because content overflowed the
       calc max-height and pinned the panel at its ceiling. With the
       cap, the panel feels like a half-screen sheet across all phone
       sizes; content that doesn't fit scrolls in .ab-body while the
       CTAs stay sticky at the bottom (see .ab-cta-sec rules above). */
    max-height: min(
      calc(100dvh - 58px - 92px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)),
      62dvh
    );
  }
  #about-panel.show {
    transform: translate(0, 0) scale(1);
  }
  #about-panel .ab-hero        { padding: 18px 18px 12px; }
  #about-panel .ab-pillars-sec { padding: 14px 18px 14px; }
  #about-panel .ab-cta-sec     { padding: 12px 18px 16px; }
  #about-panel .ab-pillar {
    grid-template-columns: 84px 1fr;
    font-size: 11.5px;
  }
}

/* Landscape phone: trigger anchors TOP-LEFT (matching where the About
   panel itself opens), not top-centre. Reasoning:
   - The URL bar already eats the top-centre real estate on iOS Safari
     landscape; a centred trigger reads as competing with the URL.
   - The right edge is held by the dock pill; centring the trigger
     visually splits attention with the dock.
   - About panel itself anchors top-left in this viewport, so the
     trigger and its disclosure should live in the same zone â€” the
     panel reads as "expanding out of the trigger" rather than
     teleporting across the screen.
   iPad / phone-portrait / desktop keep the centred trigger â€” those
   viewports don't have the same horizontal tension. */
@media (orientation: landscape) and (max-height: 520px) {
  #about-trigger {
    top:  calc(10px + env(safe-area-inset-top, 0px));
    left: calc(14px + env(safe-area-inset-left, 0px));
    transform: none;
    font-size: 10.5px;
    padding: 6px 12px 6px 10px;
  }
  /* The base :active rule recomposes translateX(-50%) scale(0.97);
     override that to keep the trigger left-anchored during the press. */
  #about-trigger:active {
    transform: scale(0.97);
  }
  #about-panel {
    top:  calc(14px + env(safe-area-inset-top, 0px));
    left: calc(14px + env(safe-area-inset-left, 0px));
    transform: translate(0, 0) scale(0.96);
    width: min(440px, 56vw);
    max-height: calc(100dvh - 28px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
  }
  #about-panel.show {
    transform: translate(0, 0) scale(1);
  }
}
