/* ============================================================================
   tokens.css — design tokens for the Daydream Music installation.

   Single source of truth for the look-and-feel. Change a value here and the
   whole UI re-themes; nothing in style.css should hardcode a color or radius
   that has a token below.

   Loaded before style.css in index.html so every rule downstream can resolve
   var(--*) references.
   ============================================================================ */
:root {

  /* ── Surfaces ─────────────────────────────────────────────────────── */
  --bg:            #000000;   /* page background, behind the HUD frame */
  --frame:         #0d0d14;   /* HUD edge bars + opaque dark surfaces  */
  --frame-line:    #20202c;   /* dividers between frame and content    */
  --sheet-bg:      #0a0a12;   /* advanced sheet background             */
  --input-bg:      #06060b;   /* text inputs, ws-url field             */
  --overlay-bg:    rgba(6, 6, 12, 0.85);   /* play-button overlay      */

  /* ── Text ─────────────────────────────────────────────────────────── */
  --text:          #d6d6e0;   /* primary text                          */
  --text-strong:   #e6e6ee;   /* headings, slider thumbs               */
  --text-dim:      #6e6e7c;   /* labels, secondary info                */

  /* ── Daydream gradient (sampled from icon_1024x1024.png) ───────────────
     Four-stop palette: teal → mustard → orange → coral. Used by ribbons,
     slider fills, the topnav avatar, and the modal accent line. The
     orange stop (--dd-3) is promoted to --accent so every existing rule
     re-themes from one place. */
  --dd-1:           #3db6be;   /* teal       (top stop)        */
  --dd-2:           #c7b566;   /* mustard    (35%)             */
  --dd-3:           #f08a48;   /* orange     (70%, accent)     */
  --dd-4:           #e84f3d;   /* coral red  (bottom)          */
  --dd-gradient:    linear-gradient(180deg,
                      var(--dd-1) 0%,
                      var(--dd-2) 35%,
                      var(--dd-3) 70%,
                      var(--dd-4) 100%);

  /* Accent role mappings — orange. Touch every slider, button, focus
     ring, MIDI badge, and the cursor by re-tinting these six tokens. */
  --accent:         #f08a48;
  --accent-hover:   #f4a56a;
  --accent-soft:    rgba(240, 138, 72, 0.08);   /* hover backgrounds   */
  --accent-medium:  rgba(240, 138, 72, 0.18);   /* active backgrounds  */
  --accent-glow:    rgba(240, 138, 72, 0.45);   /* shadow halos        */
  --accent-line:    rgba(240, 138, 72, 0.35);   /* gradient endpoints  */

  /* ── Status (distinct from accent so warnings stay legible) ───────── */
  --warn:          #ffcc00;
  --critical:      #ff5050;

  /* ── Geometry ─────────────────────────────────────────────────────── */
  --hud-thickness: clamp(56px, 6vmin, 90px);
  --radius-sm:     4px;
  --radius-md:     6px;
  --radius-lg:     8px;

  /* ── HUD tint ─────────────────────────────────────────────────────────
     Viewport-edge darkening that anchors the bar zones without drawing a
     hard line. Applied as a single inset shadow on the stage (in style.css)
     so there are no seams between bars. Heavier alpha = more "framed";
     lighter = the whole stage dissolves into the ambient. */
  --hud-tint-strong: rgba(4, 4, 8, 0.85);

  /* ── Source aspect ────────────────────────────────────────────────────
     Aspect ratio (W:H) of the centerpiece source video. The focal wrapper
     is height-bound to the viewport; this token drives its width. The
     current source (combined.mp4) is 1920×1080 outpainted from a 1440²
     original, so 16:9. If the source is replaced again, update here.
     Wrapper extends beyond viewport at very wide values; #install-video-
     area's overflow:hidden clips it, so extreme ratios just lose the
     outermost (most-outpainted, most-feathered) side content. */
  --source-aspect: 1.7778;

  /* ── Focal feather (vertical only) ────────────────────────────────────
     Soft fade at the focal's top/bottom edges, blending into the bar
     zones. Horizontal sides are handled by --side-blur-* below, not
     by this feather, so outpainted pixels are blurred *in place*
     rather than faded out. */
  --focal-feather-y: 6%;

  /* ── Side blur (covers up outpainted sides) ────────────────────────────
     Two backdrop-filter overlays sit on the focal's left and right edges
     and blur the outpainted pixels in place. Width matches the outpaint
     extent: with --source-aspect 1.7778 the central 1:1 region is 56% of
     the wrapper, so each side is (100% - 56%) / 2 ≈ 22%. Each overlay
     fades to transparent at its inner edge so the blur ramps smoothly to
     zero at the boundary of the central square. */
  --side-blur-amount: 60px;
  --side-blur-width:  22%;

  /* ── Type ─────────────────────────────────────────────────────────── */
  --font-mono:     "SF Mono", "Monaco", "Consolas", monospace;
  --tracking-wide: 0.3em;
  --tracking-bar:  0.4em;
}
/* ============================================================================
   style.css — Daydream Music installation.

   All colors, radii, and spacing tokens come from tokens.css. Nothing in this
   file should hardcode a brand color or a magic radius — if a value isn't
   semantic to the rule (e.g. a thumb's pixel size), it stays inline; if it's
   part of the design language, it goes through a token. Change tokens.css to
   re-theme.

   Layout: full-bleed video framed by four HUD edge bars + a slide-in Advanced
   sheet on the right. No timer, no expiry — runs indefinitely.
   ============================================================================ */

/* ── Reset + base ────────────────────────────────────────────────────────── */
* { box-sizing: border-box; margin: 0; padding: 0; }

html, body {
  height: 100%;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
               "Helvetica Neue", Arial, sans-serif;
  overflow: hidden;
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

/* Custom scrollbars everywhere — thin + dark, matching the panel
   chrome, instead of the OS default white bar. Applies to every scroll
   container on the page (Firefox via scrollbar-*, WebKit via the
   ::-webkit-scrollbar pseudos). Component-level rules can still
   override per-surface. */
* {
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.18);
  border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.32); }
*::-webkit-scrollbar-corner { background: transparent; }

.hidden { display: none !important; }

/* Hide the OS cursor only inside the Performance surface — useCursor
   adds `body.cursor-hidden` while it's mounted, and removes it on
   unmount. Admin pages, 404, and any non-Performance shell keep the
   normal pointer. Modals ([role="dialog"]) override below so users
   inside config/mobile-sheet/upload prompts can see their cursor. */
body.cursor-hidden,
body.cursor-hidden * {
  cursor: none !important;
}
body.cursor-hidden [role="dialog"],
body.cursor-hidden [role="dialog"] *,
/* Next.js dev overlay (compile errors, build issues panel) lives inside
   a custom <nextjs-portal> element with no [role="dialog"] — without an
   explicit exception the OS cursor disappears the moment the dev panel
   opens. Production has no <nextjs-portal>, so this rule is dev-only by
   construction. */
body.cursor-hidden nextjs-portal,
body.cursor-hidden nextjs-portal * {
  cursor: auto !important;
}

/* Custom cursor — palette-colored particle constellation + precise white
   centre dot, all rendered into a single 2D canvas so the entire effect
   is one composited GPU layer. Previous implementation stacked DOM divs
   with box-shadow + mix-blend-mode: plus-lighter, which Gecko fell back
   on software for during slider drags. cursor.ts drives drawImage from
   pre-rasterised radial-gradient sprites + globalCompositeOperation =
   "lighter" — both engines hardware-composite that path.

   body.cursor-idle fades the canvas via opacity (HW-accelerated) on the
   existing 2s idle timer. */
.cursor-canvas {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100vh;
  pointer-events: none;
  /* Sits above the modal stack (config-modal z=100, almost-ready /
     confirm-dialog z=110) so the orbital cluster + sparks paint on top
     of any open dialog. CustomCursor is mounted outside #performance
     (which has `isolation: isolate`) so this z-index lives in the root
     stacking context, not scoped to the performance subtree. */
  z-index: 150;
  opacity: 1;
  transition: opacity 280ms cubic-bezier(.2, .8, .2, 1);
  /* Force a dedicated GPU compositor layer so unrelated repaints (overlays
     opening, slider drags) never pull cursor pixels back through the main
     paint. Pairs with `desynchronized: true` on the 2D context in
     engine/cursor.ts to keep input-to-photon latency tight. */
  transform: translateZ(0);
  will-change: transform;
  contain: paint;
}

body.cursor-idle .cursor-canvas { opacity: 0; }

/* Hide the custom cursor entirely on touch-only devices. The cursor
 * was designed as a desktop mouse trail — on mobile, every tap left
 * a white dot under the user's finger which read as a stray artefact
 * over their own touch input. Matched via the hover/pointer media
 * features so it triggers on any touch-only device regardless of
 * viewport width (covers tablets in landscape and unusual aspect
 * ratios that the 768 px breakpoint would miss). */
@media (hover: none) and (pointer: coarse) {
  .cursor-canvas { display: none; }
}

/* ── Performance screen + Play overlay ───────────────────────────────────── */
#performance {
  position: fixed;
  inset: 0;
  /* --bloom-amount is written here (not on :root) so style recalc on
     every binned-kick change only walks this subtree, not the whole
     document. `isolation: isolate` promotes the scene to its own
     stacking context so the compositor can keep the rest of the page
     (cursor, anything outside #performance) on a separate layer that
     doesn't repaint when bloom changes. Avoiding `contain: paint`
     because position:fixed/absolute descendants (modals, tooltips)
     would get clipped. */
  isolation: isolate;
}

#start-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--overlay-bg);
  z-index: 20;
}

/* The brand mark IS the play button. No separate CTA on the title screen —
   the icon halo and the "click/tap to begin" whisper sit inside ONE
   <button> so anywhere on either is a hit target (testers kept clicking
   the copy itself). The button is a flex column, button chrome reset, the
   halo on top, the whisper beneath. */
.start-cta {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 18px;
  padding: 18px 40px 24px;
  margin: 0;
  background: transparent;
  border: none;
  border-radius: 24px;
  cursor: pointer;
  user-select: none;
  appearance: none;
  -webkit-appearance: none;
  transition: transform 220ms cubic-bezier(.2, .8, .2, 1),
              filter 220ms;
}
.start-cta:hover:not(:disabled) {
  transform: translate(-50%, -50%) scale(1.05);
  filter: brightness(1.18) saturate(1.1);
}
.start-cta:active:not(:disabled) {
  transform: translate(-50%, -50%) scale(0.97);
}
.start-cta:focus-visible {
  outline: none;
  filter: brightness(1.2) saturate(1.1)
          drop-shadow(0 0 12px rgba(255, 255, 255, 0.5));
}
.start-cta:disabled {
  cursor: default;
}

/* Halo container — holds the SVG ribbons + the daydream icon. Sized to
   give the writhing ribbons room to bloom outside their nominal radius. */
.start-cta-halo {
  position: relative;
  display: block;
  width: clamp(180px, 22vmin, 260px);
  height: clamp(180px, 22vmin, 260px);
  pointer-events: none;
  /* Bloom lives on this un-transformed parent (not on the SVG inside)
     so the launch-morph's scale(0.12) doesn't shrink the blur radius
     down to a sub-pixel — the bloom stays at 6px screen-pixels the
     whole way, which is what keeps the shrinking halo from reading
     as a fade. */
  filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.42));
  transition: filter 220ms;
}
.start-cta:hover .start-cta-halo {
  filter: drop-shadow(0 0 14px rgba(255, 255, 255, 0.65));
}
.start-mark {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: clamp(48px, 6vmin, 72px);
  height: auto;
  opacity: 0.95;
  z-index: 1;
  pointer-events: none;
  transition: opacity 200ms;
}
.start-cta:hover .start-mark { opacity: 1; }
.start-mark-ribbons {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;
  z-index: 0;
}
.start-mark-ribbons path {
  fill: none;
  stroke-width: 2.6px;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0.88;
  vector-effect: non-scaling-stroke;
}

/* Whisper line beneath the icon — quiet mono caps. Lives inside the same
   <button> as the halo, so a click on the copy fires onPlay too. */
.start-whisper {
  font-family: var(--font-mono);
  font-size: 0.72em;
  letter-spacing: 0.32em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.4);
  white-space: nowrap;
  animation: start-whisper-breathe 2.6s ease-in-out infinite;
}
@keyframes start-whisper-breathe {
  0%, 100% { opacity: 0.45; }
  50%      { opacity: 0.85; }
}

/* ── Launch transition ─────────────────────────────────────────────────────
   Keep this aligned with LAUNCH_DURATION_MS in StartOverlay.tsx (1300ms).

   Choreography: on click, StartOverlay.handleClick measures the SVG
   halo's current screen rect + the brand mark O's final rect and writes
   the delta translate + scale onto --halo-tx / --halo-ty / --halo-scale
   on the .start-mark-ribbons SVG. The morph keyframe consumes those
   vars, so the writhing halo shrinks and slides to the exact O slot in
   the top-left brand mark — zero opacity changes anywhere on this trip.
   The bloom around the ribbons lives on the un-transformed parent
   (.start-cta-halo), so its 6px screen-pixel blur is preserved through
   the shrink — that's the fix for the "halo looks like it's fading"
   complaint, which was actually the scaled-down drop-shadow + tapered
   strokes collapsing the halo's visual mass at small sizes. The
   daydream PNG hard-cuts (visibility:hidden) the moment launch starts.
   The brand mark O is held at visibility:hidden through the morph,
   then snaps to visibility:visible a hair before the overlay snaps to
   display:none — an instant swap from writhing halo to fixed O, with
   no opacity property touched anywhere. Concurrently the overlay
   backdrop fades to transparent so the surrounding D/E/M/N letters
   can reveal in their resting layout positions. */
.start-cta.start-cta--launching {
  pointer-events: none;
}
.start-cta.start-cta--launching .start-mark-ribbons {
  animation: start-ribbons-morph 1300ms cubic-bezier(.4, 0, .2, 1) forwards;
}
/* Strokes stay at 2.6px the whole journey. Tapering them down to match
   the brand-mark stroke weight during the shrink would just make the
   halo read as dimming. The brand-mark O sits above the halo's z layer
   and snaps in at the end, so the visual hand-off is to a thinner
   stroke instantly — there's no need to interpolate stroke widths. */
.start-cta.start-cta--launching .start-mark {
  /* Hard cut the daydream PNG the instant launch starts — no fade, no
     scale-down trip to the corner. The PNG is decoration inside the
     halo, not part of the writhing O that morphs into the brand mark,
     so the cleanest thing is for it to just stop existing the moment
     the ribbons start their trip. visibility (not opacity) so the
     existing 200ms opacity transition on .start-mark can't catch it
     and create a fade. */
  visibility: hidden;
}
.start-cta.start-cta--launching .start-whisper {
  animation: start-whisper-vanish 240ms ease forwards;
}
#start-overlay.start-overlay--launching {
  /* Reveal the brand mark behind the overlay while the halo morphs into
     its O slot. The bg shorthand here matches #start-overlay's default
     except the alpha eases to 0. */
  animation: start-overlay-backdrop-fade 1300ms cubic-bezier(.4, 0, .2, 1) forwards;
}

@keyframes start-ribbons-morph {
  0% {
    transform: translate(0, 0) scale(1);
  }
  100% {
    transform: translate(var(--halo-tx, 0px), var(--halo-ty, 0px))
               scale(var(--halo-scale, 0.12));
  }
}
@keyframes start-whisper-vanish {
  to { opacity: 0; transform: translateY(8px); }
}
@keyframes start-overlay-backdrop-fade {
  to { background: transparent; }
}


/* ── Queue scene status strip ──────────────────────────────────────────
   Bottom-of-screen status during the queued spotlight. Matches the
   .start-whisper design language so the title→queue→stage arc reads as
   one continuous typography system. */
.qs-status-text,
.qs-status-link,
.qs-status-cta {
  font-family: var(--font-mono);
  font-size: 0.72em;
  letter-spacing: 0.32em;
  font-weight: 500;
  text-transform: uppercase;
  white-space: nowrap;
}
.qs-status-text {
  color: rgba(255, 255, 255, 0.42);
  animation: start-whisper-breathe 2.6s ease-in-out infinite;
}
.qs-status-dot {
  color: rgba(255, 255, 255, 0.22);
  font-size: 0.72em;
}
.qs-status-link {
  background: transparent;
  border: none;
  padding: 0;
  cursor: pointer;
  color: rgba(255, 255, 255, 0.55);
  text-decoration: underline;
  text-underline-offset: 3px;
  transition: color 180ms cubic-bezier(.2, .8, .2, 1);
}
.qs-status-link:hover {
  color: rgba(255, 255, 255, 0.85);
}
.qs-status-cta {
  background: var(--accent-soft);
  border: 1px solid var(--accent-line);
  padding: 7px 14px;
  border-radius: 999px;
  cursor: pointer;
  color: var(--accent-hover);
  box-shadow: 0 0 14px var(--accent-glow);
  transition:
    background-color 180ms cubic-bezier(.2, .8, .2, 1),
    color 180ms cubic-bezier(.2, .8, .2, 1),
    box-shadow 180ms cubic-bezier(.2, .8, .2, 1),
    transform 140ms cubic-bezier(.2, .8, .2, 1);
}
.qs-status-cta:hover {
  background: var(--accent-medium);
  color: #ffe6cf;
  box-shadow: 0 0 22px var(--accent-glow);
  transform: scale(1.04);
}
.qs-status-cta:active {
  transform: scale(0.99);
}


/* ============================================================================
   Stage — full-bleed video framed by four HUD edge bars. In graph mode,
   when the Advanced drawer is open, the stage's bottom is pushed up so the
   entire interface (HUD edges, graph, everything) shrinks above the drawer
   instead of being overlaid by it. The transition matches the drawer slide
   so the resize lands at the same time the drawer arrives. In video mode
   (or when the drawer is closed) the stage stays full-bleed.
   ============================================================================ */
#install-stage {
  position: fixed;
  inset: 0;
  transition:
    left 320ms cubic-bezier(.2, .7, .2, 1),
    bottom 320ms cubic-bezier(.2, .7, .2, 1),
    right 320ms cubic-bezier(.2, .7, .2, 1);
}
/* Desktop (> 768px): the Full Controls panel slides in from the LEFT
   (Wave 9). The stage shrinks horizontally — its left edge moves
   inward by the panel's open width, plus a 24px breathing gap. Mobile
   keeps the bottom-sheet behavior. */
body[data-mode="graph"].drawer-open #install-stage {
  left: calc(var(--drawer-l-open, 0px) + 24px);
}
@media (max-width: 768px) {
  body[data-mode="graph"].drawer-open #install-stage {
    left: 0;
    bottom: var(--drawer-h);
  }
}

/* Single inset shadow that darkens the viewport perimeter. Reach equals
   the bar thickness so the focal well stays untouched; corners stack the
   x and y components and read naturally darker than the midpoints. This
   replaces per-bar background fills, so there are no seams where bars
   meet at the corners. */
#install-stage::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 5;
  box-shadow: inset 0 0 var(--hud-thickness) 0 var(--hud-tint-strong);
}

/* Full-bleed video area — covers the whole stage. z-index 7 puts the
   whole video subtree (canvas + side-blurs) above the perimeter HUD
   bars at z-index 6, so the alpha-keyed canvas can paint the subject
   (and its bloom halo) over the bars. Transparent regions of the
   canvas (pure black in the source) leave the bars visible behind. */
#install-video-area {
  position: absolute;
  inset: 0;
  background: transparent;
  overflow: hidden;
}

/* Ambient backdrop hidden — background is pure black so the focal
   square is the only thing the eye locks onto. */
#install-ambient { display: none; }

/* Focal video wrapper — height-bound, centered horizontally, width
   driven by --source-aspect so we use as much of the (possibly out-
   painted) source as the source actually contains. Vertical feather
   blends top/bottom into the bar zones; horizontal sides are handled
   by .focal-side-blur below — the outpainted pixels stay visible but
   are progressively blurred in place. */
#install-video-area #video-wrap {
  position: absolute;
  top: 0; bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  height: 100%;
  width: auto;
  aspect-ratio: var(--source-aspect);
  z-index: 1;
  -webkit-mask-image:
    linear-gradient(to bottom, transparent 0%, black var(--focal-feather-y),
                    black calc(100% - var(--focal-feather-y)), transparent 100%);
          mask-image:
    linear-gradient(to bottom, transparent 0%, black var(--focal-feather-y),
                    black calc(100% - var(--focal-feather-y)), transparent 100%);
}

/* Side blur overlays — two strips on the focal's left and right edges
   that backdrop-blur the outpainted pixels behind them. Each is
   --side-blur-width wide; the inner edge fades to transparent so the
   blur ramps smoothly to zero at the boundary of the original 1:1
   square in the centre. Two separate elements (rather than one with a
   centre-cut mask) avoids known Chromium issues with combining
   mask-image and backdrop-filter on a single element. */
.focal-side-blur {
  position: absolute;
  top: 0;
  bottom: 0;
  width: var(--side-blur-width);
  pointer-events: none;
  -webkit-backdrop-filter: blur(var(--side-blur-amount));
          backdrop-filter: blur(var(--side-blur-amount));
}
/* Non-linear mask: opaque (full blur) for the outer 70% of the overlay,
   then a quick non-linear falloff in the inner 30% so the outpainted
   regions are fully obscured rather than fading gradually inward. The
   curve uses approximated ease-out stops to avoid a visible linear ramp. */
.focal-side-blur-left {
  left: 0;
  -webkit-mask-image: linear-gradient(to right,
    black 0%,
    black 70%,
    rgba(0,0,0,0.85) 80%,
    rgba(0,0,0,0.55) 88%,
    rgba(0,0,0,0.20) 95%,
    transparent 100%);
          mask-image: linear-gradient(to right,
    black 0%,
    black 70%,
    rgba(0,0,0,0.85) 80%,
    rgba(0,0,0,0.55) 88%,
    rgba(0,0,0,0.20) 95%,
    transparent 100%);
}
.focal-side-blur-right {
  right: 0;
  -webkit-mask-image: linear-gradient(to right,
    transparent 0%,
    rgba(0,0,0,0.20) 5%,
    rgba(0,0,0,0.55) 12%,
    rgba(0,0,0,0.85) 20%,
    black 30%,
    black 100%);
          mask-image: linear-gradient(to right,
    transparent 0%,
    rgba(0,0,0,0.20) 5%,
    rgba(0,0,0,0.55) 12%,
    rgba(0,0,0,0.85) 20%,
    black 30%,
    black 100%);
}

/* Crossfade between video-a and video-b layers (driven by VideoLayer).
   Source aspect matches the wrapper, so cover/contain are equivalent —
   cover is kept for resilience if a future video has a different aspect. */
#video-a, #video-b {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
  opacity: 0;
  transition: opacity 1.5s ease-in-out;
}

/* Shader output canvas. Sits above the <video> siblings so the WebGL
   pipeline's rendered frame is what the audience sees; opaque, so the
   videos beneath are completely covered while it's up. No z-index on
   purpose — DOM order is canvas → focal-side-blur, so the backdrop-
   filter blur strips still sit above the canvas and feather the
   outpainted gutters of the shader output. Starts at opacity 0 so the
   raw <video> shows through until the shader pipeline produces its
   first frame; effects.js adds .effects-ready on the first successful
   render and the CSS transition crossfades the shader output in,
   covering the cold-load gap. If effects fail to initialise (no
   WebGL2, etc.) app.js sets display:none here and the underlying
   video elements become the permanent fallback. */
#effects-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  opacity: 0;
  transition: opacity 500ms ease-out;
}
#effects-canvas.effects-ready { opacity: 1; }

/* Legacy HUD canvas always hidden. The graph swaps with the video subtree
   based on body[data-mode]; default is "graph". */
#install-video-area #hud { display: none; }
/* Graph wrap reserves a generous gap past the ribbon canvas + its bloom
   drop-shadow halo (which can extend ~11 px past the canvas at peak
   --bloom-amount). Using --hud-thickness as the gap scales the breathing
   room with the rest of the HUD. */
#install-video-area #graph-wrap { position: absolute; inset: calc(var(--hud-thickness) * 2 + var(--ribbon-bleed)); z-index: 2; }
@media (max-width: 768px) {
  /* On mobile the bottom of the moving-lines canvas was getting
   * tucked under the LiteControls bay. Anchor the bottom inset to
   * the live bay height (see ``--lite-controls-h``, set by
   * LiteControls' ResizeObserver). top stays at the default
   * (hud-thickness * 2 + ribbon-bleed); only the bottom is
   * overridden so the lines never disappear behind the bay. */
  #install-video-area #graph-wrap {
    inset: calc(var(--hud-thickness) * 2 + var(--ribbon-bleed))
           calc(var(--hud-thickness) * 2 + var(--ribbon-bleed))
           calc(var(--lite-controls-h, 50vh) + 8px)
           calc(var(--hud-thickness) * 2 + var(--ribbon-bleed));
  }
  /* Heavier top/bottom feathering on phones so the line ends don't
   * read as a hard horizontal cut against the visualizer below. The
   * desktop default 36 px gets lost on the larger relative-feather
   * area mobile uses; 80 px gives a calmer fade against the bay. */
  #install-video-area #graph {
    --graph-feather-y: 80px;
  }
}
#install-video-area #graph {
  width: 100%; height: 100%; display: block;
  /* Stronger feathering: the centered playhead exposes both edges, and
     the user wants old samples to fade out aggressively as they drift
     left, plus a softer right-edge entry. Tuned to the canvas width
     bands (clamps so the feather reads on small + huge viewports). */
  --graph-feather-x: clamp(80px, 14vw, 220px);
  --graph-feather-y: 36px;
  -webkit-mask-image:
    linear-gradient(to right,  transparent 0, black var(--graph-feather-x), black calc(100% - var(--graph-feather-x)), transparent 100%),
    linear-gradient(to bottom, transparent 0, black var(--graph-feather-y), black calc(100% - var(--graph-feather-y)), transparent 100%);
          mask-image:
    linear-gradient(to right,  transparent 0, black var(--graph-feather-x), black calc(100% - var(--graph-feather-x)), transparent 100%),
    linear-gradient(to bottom, transparent 0, black var(--graph-feather-y), black calc(100% - var(--graph-feather-y)), transparent 100%);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
}
/* Lanes mode (Concept A) — kill the horizontal mask feather. The mask
   above was tuned for polyline mode (old samples drift left and fade
   out aggressively). In lanes mode the LEFT feather zone overlaps the
   "track header" gutter (occluding labels + color tags) and the RIGHT
   feather zone overlaps the playhead area (vanishing anchor dots + the
   freshest portion of every contour). Setting `--graph-feather-x: 0`
   collapses the transparent stops of the horizontal gradient onto
   x = 0 / x = 100% — the mask becomes a no-op horizontally while the
   vertical feather (`--graph-feather-y`) still softens the top/bottom
   canvas edges. The `data-graph-layout` attribute is set by
   <GraphLaneLabels/>'s 20 Hz poll from the renderer's layoutMode. */
#install-video-area #graph-wrap[data-graph-layout="lanes"] #graph {
  --graph-feather-x: 0px;
}
body:not([data-mode="graph"]) #install-video-area #graph-wrap { display: none; }
/* Title-screen guard: before the user clicks Play, session is idle and
   the graph + playhead would draw through the start overlay. Hide the
   whole subtree so the canvas isn't painting under a black screen. */
body[data-session="idle"] #install-video-area #graph-wrap { display: none; }
body[data-mode="graph"] #install-video-area #video-wrap,
body[data-mode="graph"] #install-video-area #effects-canvas,
body[data-mode="graph"] #install-video-area .focal-side-blur { display: none; }

/* ── Graph click-to-pause overlay ────────────────────────────────────────
   Full-bleed transparent button on top of the graph canvas; makes the
   whole graph area a click target. The centered glyph fades in/out as a
   transient confirmation when toggled (and once on first ready as a
   discovery hint — see GraphPauseOverlay.tsx). */
#install-video-area #graph-wrap .graph-pause-overlay {
  position: absolute;
  inset: 0;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
  z-index: 3;
  -webkit-appearance: none;
  appearance: none;
}
#install-video-area #graph-wrap .graph-pause-overlay:focus-visible {
  outline: none;
}
/* Touch devices: every tap on the graph would call togglePauseAndAudio →
   AudioContext.suspend(), and on Android that visibly blanks the audio-
   driven render pipeline (analyser-fed bloom + FluidSim) for ~1 frame
   while the ⏸/▶ glyph flashes in. The OperatorStrip play/pause button
   is the canonical mobile control; leave the element in the DOM so the
   one-shot discovery flash on first `ready` still surfaces. */
@media (pointer: coarse) {
  #install-video-area #graph-wrap .graph-pause-overlay {
    pointer-events: none;
  }
}

/* DAW-style lane labels — color-matched name pills anchored at each
   line's playhead intersection. Positioned absolutely inside
   #graph-wrap so they overlay the canvas; pointer-events: none keeps
   them out of the pause-overlay click target. Pills update at 20 Hz
   from GraphLaneLabels' setInterval — not in the canvas hot loop. */
#install-video-area #graph-wrap .graph-lane-labels {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 2;
  /* Below the pause overlay (z=3) but above the canvas (z=auto). */
}
.graph-lane-label {
  position: absolute;
  /* Anchor pill's vertical center at top:Ny.
     Polylines mode: pill placed 8px to the right of the playhead at the
       polyline's y intersection.
     Lanes mode (Concept A): pill placed 8px from the canvas left edge
       at the lane band's vertical center. Same transform works for
       both — only the renderer-supplied left coordinate differs. */
  transform: translate(8px, -50%);
  padding: 1px 6px 2px;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  line-height: 1.1;
  border: 1px solid;
  border-radius: 3px;
  background: rgba(6, 6, 12, 0.78);
  -webkit-backdrop-filter: blur(2px);
          backdrop-filter: blur(2px);
  white-space: nowrap;
  /* Fade in when a new lane appears so a slider drag doesn't pop the
     pill in jarringly. Position transitions are off — the pill needs
     to track its lane sample-for-sample, not lerp behind it. */
  animation: graph-lane-label-fade-in 200ms ease-out both;
}
/* Idle lane (no movement in the last LANE_IDLE_MS) — pill dims to
   match the renderer's envelope dim factor (LANE_IDLE_ALPHA = 0.4).
   Opacity transition is gentle enough that touching the slider fades
   the pill back in without flicker. */
.graph-lane-label--idle {
  opacity: 0.4;
  transition: opacity 200ms cubic-bezier(.2, .8, .2, 1);
}
/* Lanes-mode safety net for label overflow. The gutter is 168 px;
   pills are inset 8 px from the canvas left and have ~12 px of
   horizontal padding + border, so ~148 px of text room. Cap max-width
   slightly tighter so very long labels (long LoRA names that the
   lora_str_ prefix strip didn't fully fix) clip with an ellipsis
   instead of bleeding past the gutter divider into the envelope. */
.graph-lane-labels--lanes .graph-lane-label {
  max-width: 144px;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* "+N more" overflow chip — rendered below the stack when more lanes
   qualify than MAX_LANES (10). Neutral palette: no param-color border
   or text, because this isn't a lane, it's a continuation marker. The
   render component intentionally omits inline color/borderColor for
   this variant so these CSS rules win. */
.graph-lane-label--more {
  color: var(--text-dim);
  border-color: var(--frame-line);
  background: rgba(6, 6, 12, 0.78);
  font-size: 9px;
  /* Slightly tighter than lane labels — secondary information. */
  letter-spacing: 0.25em;
}
@keyframes graph-lane-label-fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

.graph-pause-overlay__glyph {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 96px;
  height: 96px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--overlay-bg);
  color: rgba(255, 255, 255, 0.92);
  font-size: 40px;
  line-height: 1;
  letter-spacing: 0;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, -50%) scale(0.92);
  transition:
    opacity 240ms cubic-bezier(.2, .8, .2, 1),
    transform 240ms cubic-bezier(.2, .8, .2, 1);
}
.graph-pause-overlay.is-flashing .graph-pause-overlay__glyph {
  opacity: 0.95;
  transform: translate(-50%, -50%) scale(1);
}

/* ── Schedule Curves overlay ─────────────────────────────────────────────
   Full-bleed semi-transparent pane over the graph canvas. The user draws
   per-param automation curves here against the live track waveform. Sits
   above .graph-pause-overlay (z=3) — when both could be visible the
   curves overlay wins. Mounted in #graph-wrap by InstallStage; gated on
   useCurveStore.overlayOpen.

   Vocabulary: same frosted-glass palette as .halo-menu / .status-bar.
   The two stacked <canvas>es (waveform underlay + curves) are absolutely
   positioned and stretch to fill the drawing area; the tab strip pins to
   the bottom and reserves its own 44px row. */
.schedule-curves-overlay {
  /* Viewport-fixed so it claims a full chunk of the screen regardless
     of where the moving-lines graph (#graph-wrap) sits, and regardless
     of whether the AdvancedDrawer is open underneath. Z-index above
     the drawer (z=50) so it covers the drawer when both would overlap.

     The horizontal insets clear the DesktopEdgeDrag LoRA sliders on
     the left and right edges (each takes
     hud-thickness + ribbon-bleed + remix-hint-extend ≈ 145-195px from
     its viewport edge). The bottom inset clears the AdvancedDrawer
     handle (always visible at the bottom of the viewport, drawer open
     or closed). +12px breathing room on each side so the curve area
     never butts directly against neighbouring chrome — combined with
     EDGE_PADDING_PX inside the canvas, control points at the extremes
     stay fully reachable without ever overlapping any other UI. */
  position: fixed;
  top: 70px;
  left: calc(
    var(--hud-thickness) + var(--ribbon-bleed) + var(--remix-hint-extend) + 12px
    + var(--drawer-l-open, 0px)
  );
  right: calc(var(--hud-thickness) + var(--ribbon-bleed) + var(--remix-hint-extend) + 12px);
  /* Hold the overlay above the hero-bay's bottom anchor so the curve
     canvas never sits under the bottom control row (hero macros, now
     playing, save pill). hud + 24 = bay's bottom inset; + 100px clears
     the bay's typical height; + 12px breathing gap. */
  bottom: calc(var(--hud-thickness) + 24px + 100px + 12px);
  z-index: 51;
  transition: left 320ms cubic-bezier(.2, .7, .2, 1);
  display: flex;
  flex-direction: column;
  /* Semi-transparent dark wash with a subtle inset frame so the
     curve area reads as a recessed bay — closer to a hardware
     automation lane than a flat overlay. Backdrop blur is intentionally
     OFF; the moving lines under the curves stay legible. */
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(0, 0, 0, 0) 60%),
    rgba(6, 6, 12, 0.38);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.05),
    inset 0 -1px 0 rgba(0, 0, 0, 0.6),
    0 6px 18px rgba(0, 0, 0, 0.5);
  overflow: hidden;
}

/* Stacked canvases share the same drawing region (everything above the
   tabs). The bg canvas paints the waveform silhouette; the foreground
   canvas paints the curve, control points, and playhead. */
.schedule-curves-bg,
.schedule-curves-canvas {
  position: absolute;
  inset: 0 0 44px 0;
  width: 100%;
  pointer-events: none;
}
.schedule-curves-canvas {
  pointer-events: auto;
  cursor: crosshair;
  z-index: 1;
}

.schedule-curves-tabs {
  margin-top: auto;
  display: flex;
  gap: 6px;
  align-items: center;
  padding: 6px 8px;
  background: rgba(6, 6, 12, 0.7);
  border-top: 1px solid rgba(255, 255, 255, 0.12);
  z-index: 2;
  position: relative;
}

.schedule-curves-tab {
  font-family: var(--font-mono);
  letter-spacing: 0.32em;
  text-transform: uppercase;
  font-size: 0.66em;
  padding: 6px 10px;
  border: 1px solid rgba(255, 255, 255, 0.15);
  background: transparent;
  color: rgba(255, 255, 255, 0.55);
  border-radius: 3px;
  cursor: pointer;
  transition:
    color 140ms ease,
    border-color 140ms ease,
    box-shadow 200ms ease;
}
.schedule-curves-tab:hover {
  color: rgba(255, 255, 255, 0.85);
  border-color: rgba(255, 255, 255, 0.3);
}
.schedule-curves-tab--enabled::after {
  /* A small dot to the right of the label signals "this curve is
     actually driving the param right now" — different from "active"
     (= currently being edited in the canvas). */
  content: "";
  display: inline-block;
  width: 5px;
  height: 5px;
  margin-left: 7px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 6px rgba(240, 138, 72, 0.6);
  vertical-align: middle;
}
.schedule-curves-tab--active {
  color: var(--accent);
  border-color: rgba(240, 138, 72, 0.45);
  box-shadow: 0 0 12px -4px rgba(240, 138, 72, 0.45);
}

/* LoRA tabs — same shape as standard tabs but with a subtle violet
   accent at the start so users can tell them apart from the fixed
   six. Matches the LoRA-side ribbon palette in the rest of the UI. */
.schedule-curves-tab--lora {
  border-left: 2px solid rgba(176, 140, 232, 0.55);
}
.schedule-curves-tab--lora.schedule-curves-tab--active {
  border-left-color: rgba(176, 140, 232, 0.95);
}

/* Master ON/OFF — kill-switch for ALL curves. Pinned right of the
   tabs, before the close button. Visually distinct so users notice
   it: green when active (curves driving), dim grey when paused. */
.schedule-curves-master {
  margin-left: auto;
  font-family: var(--font-mono);
  letter-spacing: 0.32em;
  text-transform: uppercase;
  font-size: 0.62em;
  padding: 6px 12px;
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: transparent;
  color: rgba(255, 255, 255, 0.55);
  border-radius: 3px;
  cursor: pointer;
  transition:
    color 140ms ease,
    border-color 140ms ease,
    box-shadow 200ms ease;
}
.schedule-curves-master:hover {
  color: rgba(255, 255, 255, 0.85);
  border-color: rgba(255, 255, 255, 0.3);
}
.schedule-curves-master--on {
  color: rgba(150, 220, 160, 1);
  border-color: rgba(120, 200, 130, 0.55);
  box-shadow: 0 0 12px -4px rgba(120, 200, 130, 0.45);
}
.schedule-curves-master--on:hover {
  color: rgba(180, 240, 190, 1);
}

.schedule-curves-close {
  /* margin-left: auto used to push us to the right; .schedule-curves-master
     now owns that auto-margin, so we just sit right of the master toggle. */
  font-family: var(--font-mono);
  font-size: 1em;
  width: 28px;
  height: 28px;
  border: 1px solid rgba(255, 255, 255, 0.15);
  border-radius: 3px;
  background: transparent;
  color: rgba(255, 255, 255, 0.7);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.schedule-curves-close:hover {
  color: rgba(255, 255, 255, 0.95);
  border-color: rgba(255, 255, 255, 0.3);
}

/* Preset menu — opens on tab right-click. Frosted-glass card with
   Enable/Disable + Reset + a list of musical presets. Positioned
   absolutely relative to the overlay (the component sets x/y inline). */
.schedule-curves-preset-menu {
  position: absolute;
  z-index: 5;
  min-width: 180px;
  padding: 6px;
  background: rgba(6, 6, 12, 0.92);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 4px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55);
  /* Cap the height so a tall menu doesn't extend off the top of the
     overlay. The component anchors the menu at the tab's top edge and
     grows upward; on narrow viewports the available height shrinks. */
  max-height: calc(100% - 60px);
  overflow-y: auto;
}

.schedule-curves-preset-header {
  font-family: var(--font-mono);
  letter-spacing: 0.32em;
  text-transform: uppercase;
  font-size: 0.62em;
  color: rgba(255, 255, 255, 0.55);
  padding: 6px 10px 8px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  margin-bottom: 4px;
}

.schedule-curves-preset-item {
  display: block;
  width: 100%;
  text-align: left;
  font-family: var(--font-mono);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  font-size: 0.66em;
  padding: 7px 10px;
  border: 0;
  background: transparent;
  color: rgba(255, 255, 255, 0.78);
  cursor: pointer;
  border-radius: 3px;
}
.schedule-curves-preset-item:hover {
  background: rgba(240, 138, 72, 0.18);
  color: rgba(255, 255, 255, 0.98);
}

.schedule-curves-preset-divider {
  height: 1px;
  margin: 4px 6px;
  background: rgba(255, 255, 255, 0.08);
}

/* ── HUD edges ───────────────────────────────────────────────────────────── */
/* Bars are transparent containers for labels + level meters. The viewport
   edge darkening lives on #install-stage::before instead, so the same
   ambient image runs continuously through the bar zones. z-index sits
   above the stage darkening overlay so meters and labels paint cleanly. */
.install-edge {
  position: absolute;
  color: var(--text-dim);
  font-size: 22px;
  letter-spacing: var(--tracking-bar);
  text-transform: uppercase;
  user-select: none;
  z-index: 6;
  --fill: 0;
}
/* Visible divider under the topnav band — the wordmark, halo, and
   pills sit roughly at `hud-thickness / 2` from the top of the page;
   this hairline sits just below the topnav band so the chrome reads
   as a distinct strip from the canvas content. body::before is the
   anchor because the top HUD edge bar was retired (HeroMacros DENOISE
   covers that drag now), so there's no .install-edge-top element to
   key off. */
body[data-mode="graph"]::before {
  content: "";
  position: fixed;
  /* Sit well below the topnav band. The wordmark + halo + pills cluster
     around y ≈ hud-thickness (their bbox bottoms range hud-9 → hud+1
     depending on element). Pushing the divider to hud + 18 leaves a
     comfortable ~18px breathing gap before the line + a soft 18px
     gradient halo dropping into the canvas. */
  top: calc(var(--hud-thickness) + 18px);
  left: 4%;
  right: 4%;
  height: 18px;
  background:
    linear-gradient(
      to right,
      transparent 0%,
      var(--accent-line) 12%,
      var(--accent-line) 88%,
      transparent 100%
    ) top / 100% 1px no-repeat,
    linear-gradient(
      to bottom,
      rgba(0, 0, 0, 0.4) 0%,
      transparent 100%
    );
  opacity: 0.9;
  pointer-events: none;
  z-index: 6;
}
.install-edge .install-edge-label {
  position: absolute;
  z-index: 2;
  text-shadow: 0 1px 2px #000;
}
/* Thin colored ribbons along each slider edge — see ribbons.js. Sized
   close to the original 2 px line so they read as a quiet perimeter
   signal, not a focal element. SVG fills the bar zone and is allowed
   to bleed slightly into the video gutter for the writhing.
   vector-effect:non-scaling-stroke so stroke widths are real CSS
   pixels regardless of viewBox aspect. Stroke-width, opacity, and a
   soft white halo all grow with --bloom-amount so the perimeter
   pulses with the shader bloom on the video without drawing
   attention. White (not amber) — chosen for neutrality so the halo
   reads as luminance rather than a UI accent color. */
.install-ribbons {
  position: absolute;
  pointer-events: none;
  overflow: visible;
  filter: drop-shadow(0 0 calc(1px + var(--bloom-amount, 0) * 10px) rgba(255, 255, 255, 0.55));
  /* Canvas-rendered (was SVG). Strokes/opacity that used to live in CSS
     are applied in tickRibbons to mirror the same contract. The canvas
     extends past the host on the inward edge so the writhing curls that
     bleed into the central gutter don't get clipped — SVG used overflow:
     visible to bleed; canvas needs the bitmap to actually extend. The
     bleed amount is set per-edge below. No will-change: it would clip
     the canvas to its layer surface and chop the bleed. */
}
/* Inward bleed so the writhing curls (which can reach ~30% past the
   viewBox boundary, plus the arrowhead curl on top of that) don't get
   clipped by the canvas bitmap. Tied to --hud-thickness so it scales
   with viewport: 56px host → 28px bleed, 90px host → 45px bleed,
   which leaves clear headroom for the writhe peak + curl tip. */
:root {
  --ribbon-bleed: calc(var(--hud-thickness) * 0.5);
}
/* `<canvas>` is a replaced element with an intrinsic 300x150 box; CSS
   anchor positioning (top/left/right/bottom) won't stretch it like a
   block. Set explicit width/height so the canvas fills the host PLUS
   the inward bleed where the writhing curls land. */
/* Trailing-end fade on the along axis. The writhe path has a hard endpoint
   at along=0 — without a taper the strokes terminate flush, which reads as
   a clip. Mask the canvas so the trailing end fades to transparent over
   --ribbon-tail-fade, with the 0% point landing exactly at the writhe
   path's endpoint (16 px inside the canvas, matching ALONG_END_INSET_PX
   in ribbons.ts). The leading end (with the arrowhead curl) stays fully
   opaque. Per-bar direction:
     - top   : fade the LEFT  end (leading edge / curl is at the right)
     - left  : fade the BOTTOM end (leading edge / curl is at the top)
     - right : fade the BOTTOM end (same flipAlong as left bar) */
:root {
  --ribbon-tail-fade: 80px;
  --ribbon-along-inset: 16px; /* mirrors ALONG_END_INSET_PX in ribbons.ts */
}
.install-edge-top .install-ribbons {
  top: 0;
  left: 0;
  width: 100%;
  height: calc(100% + var(--ribbon-bleed));
  /* Symmetric fade at BOTH ends — the trailing edge (left) where the
     ribbon writhe begins AND the far edge (right) where the gray
     "track" ribbons terminate at the bar's outer end. Same
     ribbon-tail-fade width on each side so the bar reads as
     symmetric. */
  -webkit-mask-image: linear-gradient(to right, transparent var(--ribbon-along-inset), black calc(var(--ribbon-along-inset) + var(--ribbon-tail-fade)), black calc(100% - var(--ribbon-along-inset) - var(--ribbon-tail-fade)), transparent calc(100% - var(--ribbon-along-inset)));
          mask-image: linear-gradient(to right, transparent var(--ribbon-along-inset), black calc(var(--ribbon-along-inset) + var(--ribbon-tail-fade)), black calc(100% - var(--ribbon-along-inset) - var(--ribbon-tail-fade)), transparent calc(100% - var(--ribbon-along-inset)));
}
.install-edge-left .install-ribbons {
  top: 0;
  left: 0;
  width: calc(100% + var(--ribbon-bleed));
  height: 100%;
  /* Fade BOTH ends: the trailing edge (bottom, where the writhe
     begins) and the far edge (top, where the gray "track" ribbons
     terminate at the bar's outer end). flipAlong=true means
     "along=ALONG" maps to canvas top. */
  -webkit-mask-image: linear-gradient(to bottom, transparent var(--ribbon-along-inset), black calc(var(--ribbon-along-inset) + var(--ribbon-tail-fade)), black calc(100% - var(--ribbon-along-inset) - var(--ribbon-tail-fade)), transparent calc(100% - var(--ribbon-along-inset)));
          mask-image: linear-gradient(to bottom, transparent var(--ribbon-along-inset), black calc(var(--ribbon-along-inset) + var(--ribbon-tail-fade)), black calc(100% - var(--ribbon-along-inset) - var(--ribbon-tail-fade)), transparent calc(100% - var(--ribbon-along-inset)));
}
.install-edge-right .install-ribbons {
  top: 0;
  /* bleed sits to the LEFT of the host; shift the canvas left by that
     amount so its right edge stays aligned with the host's right edge
     (= screen edge). */
  left: calc(-1 * var(--ribbon-bleed));
  width: calc(100% + var(--ribbon-bleed));
  height: 100%;
  /* Fade BOTH ends — same as left bar (mirrored layout, same mask
     applies). */
  -webkit-mask-image: linear-gradient(to bottom, transparent var(--ribbon-along-inset), black calc(var(--ribbon-along-inset) + var(--ribbon-tail-fade)), black calc(100% - var(--ribbon-along-inset) - var(--ribbon-tail-fade)), transparent calc(100% - var(--ribbon-along-inset)));
          mask-image: linear-gradient(to bottom, transparent var(--ribbon-along-inset), black calc(var(--ribbon-along-inset) + var(--ribbon-tail-fade)), black calc(100% - var(--ribbon-along-inset) - var(--ribbon-tail-fade)), transparent calc(100% - var(--ribbon-along-inset)));
}
.install-edge.install-edge-pulse .install-edge-bar { opacity: 1; }
.install-edge.install-edge-pulse .install-edge-label {
  color: var(--text);
  transition: color 200ms;
}

/* TOP — fills left → right. Indented by --hud-thickness on each side
   so the top-left and top-right corners are bare, matching the way
   the left/right bars stop short of the top/bottom corners. */
.install-edge-top {
  top: 0;
  /* Bookended on BOTH sides by corner anchors. Right inset terminates
     before the halo badge (right: hud + 24, width 44 → left edge at
     hud + 68; 12px gap = hud + 80). Left inset reserves room for an
     anchor of comparable scale to the halo plus a generous 64px breathing
     gap before the ribbon begins (left: hud + 144). The asymmetric gap
     accommodates a downstream consumer with a hard-edged rectangular
     anchor — demon-public-demo's saved-sessions Library cassette —
     where the halo's soft writhing ring needs less air than a tilted
     rectangle. With no consumer mounted, the slot is simply blank. */
  left: calc(var(--hud-thickness) + 144px);
  right: calc(var(--hud-thickness) + 80px);
  height: var(--hud-thickness);
}
/* Bias the label down ~5px from the bar's vertical mid-line so it (and
   the mirrored halo badge) get more breathing room from the very top
   screen edge. The halo offsets by the same amount via --top-bias below. */
.install-edge-top .install-edge-label {
  top: calc(50% + var(--top-bias, 5px));
  left: 24px;
  transform: translateY(-50%);
}
.install-edge-top .install-edge-bar {
  bottom: 0; left: 0; height: 2px; width: 100%;
  transform-origin: left bottom;
  /* scaleY pulses the bar's thickness with bloom so it visibly fattens on
     kick rather than just acquiring a halo. */
  transform: scaleX(var(--fill)) scaleY(calc(1 + var(--bloom-amount, 0) * 3));
}

/* LEFT + RIGHT — fill bottom → top. The vertical extent is constrained
   so the side ribbons sit between the corner anchors: HaloBadge (top-right)
   and Turntable (bottom-right above the bottom HUD bar). The left bar
   mirrors the same bounds for visual symmetry even though it has no
   anchored elements on desktop. Math for the bottom inset: turntable bottom
   is (hud + 24) from viewport bottom, height 70 → bbox top at (hud + 94),
   plus a ~25px disc drop-shadow + bloom-glow halo above the bbox → visible
   top at ~(hud + 119), plus 25px breathing room → bar bottom at (hud + 144). */
.install-edge-left,
.install-edge-right {
  top: var(--hud-thickness);
  bottom: calc(var(--hud-thickness) + 144px);
  width: var(--hud-thickness);
}
.install-edge-left  { left: 0;  }
.install-edge-right { right: 0; }

.install-edge-left  .install-edge-label,
.install-edge-right .install-edge-label {
  bottom: 24px; left: 50%; transform: translateX(-50%);
  writing-mode: vertical-rl; text-orientation: mixed;
}
.install-edge-left  .install-edge-bar,
.install-edge-right .install-edge-bar {
  bottom: 0; width: 2px; height: 100%;
  /* scaleX pulses bar thickness on bloom (same idea as the top bar). */
  transform: scaleY(var(--fill)) scaleX(calc(1 + var(--bloom-amount, 0) * 3));
}
.install-edge-left  .install-edge-bar { right: 0; transform-origin: right bottom; }
.install-edge-right .install-edge-bar { left:  0; transform-origin: left  bottom; }

/* ── StylePanel ──────────────────────────────────────────────────────
   Left-edge bay holding the two LoRA style faders. Pulled to the
   LEFT (was right) so the right edge stays clear for the Full
   Controls sidebar (W5-C). Label says "Style" — these are LoRA
   strengths, not master volumes; the prior "Master" wording set the
   wrong expectation. Same recessed-bay vocabulary as HeroMacros. */
.style-panel {
  position: fixed;
  top: 50%;
  /* Left inset shares the same corner-inset math as the wordmark
     (.library-button) so the left edges of wordmark, LoRA faders, and
     anything else pinned to the left form a single vertical guide. */
  left: max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-left, 0px));
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px 14px;
  background: rgba(6, 6, 12, 0.62);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.04),
    0 4px 14px rgba(0, 0, 0, 0.4);
  -webkit-backdrop-filter: blur(8px);
          backdrop-filter: blur(8px);
  z-index: 5;
}
.style-panel-label {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-dim);
  text-align: center;
}
.style-panel-faders {
  display: flex;
  flex-direction: row;
  gap: 14px;
  align-items: stretch;
}
.style-fader {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  width: 36px;
}
.style-fader-label {
  font-family: var(--font-mono);
  font-size: 8px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-dim);
  text-align: center;
  line-height: 1.2;
  width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.style-fader-track {
  position: relative;
  width: 100%;
  height: 180px;
  cursor: ns-resize;
  touch-action: none;
}
.style-fader-track::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 0; bottom: 0;
  width: 4px;
  transform: translateX(-50%);
  background: linear-gradient(
    to right,
    rgba(0, 0, 0, 0.6) 0%,
    rgba(0, 0, 0, 0.45) 50%,
    rgba(255, 255, 255, 0.04) 100%
  );
  border-radius: 2px;
  box-shadow:
    inset 1px 0 0 rgba(0, 0, 0, 0.7),
    inset -1px 0 1px rgba(255, 255, 255, 0.05);
}
.style-fader-fill {
  position: absolute;
  bottom: 0;
  left: 50%;
  width: 4px;
  transform: translateX(-50%);
  background: linear-gradient(
    to top,
    color-mix(in srgb, var(--accent) 70%, #000) 0%,
    var(--accent) 100%
  );
  border-radius: 2px;
  pointer-events: none;
  transition: height 70ms linear;
}
.style-fader-cap {
  position: absolute;
  left: 50%;
  width: 26px;
  height: 10px;
  background: linear-gradient(
    to bottom,
    rgb(78, 84, 96) 0%,
    rgb(42, 46, 54) 48%,
    rgb(18, 20, 24) 100%
  );
  border-radius: 2px;
  transform: translate(-50%, 50%);
  pointer-events: none;
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.65),
    0 0 0 1px rgba(0, 0, 0, 0.55),
    inset 0 1px 0 rgba(255, 255, 255, 0.15),
    inset 0 -1px 0 rgba(0, 0, 0, 0.5);
  transition: bottom 70ms linear;
}
.style-fader-cap::before {
  content: "";
  position: absolute;
  left: 2px;
  right: 2px;
  top: 50%;
  height: 1px;
  background: var(--accent);
  transform: translateY(-50%);
  border-radius: 1px;
  box-shadow: 0 0 3px var(--accent);
}
.style-fader-value {
  font-family: var(--font-mono);
  font-size: 9px;
  color: var(--text);
}
.style-fader--empty {
  opacity: 0.35;
}
.style-fader--empty .style-fader-track {
  cursor: default;
}
/* Mobile: hide the master panel — the existing LiteControls + mobile
   rails cover LoRA strength there. */
@media (max-width: 768px) {
  .style-panel {
    display: none;
  }
}
/* Desktop: the left/right edge containers stay in the DOM as mount
   points (useEdgeLoraBinding + MobileStepperRail bind to them), but
   their visual content is hidden — the right-side <MasterPanel/> is
   the only LoRA surface now. */
.install-edge-left .install-ribbons,
.install-edge-right .install-ribbons,
.install-edge-left .install-edge-bar,
.install-edge-right .install-edge-bar,
.install-edge-left .install-edge-label,
.install-edge-right .install-edge-label {
  opacity: 0;
}
/* Mobile keeps its rail visual via MobileStepperRail rendering on top
   of the same containers; only the writhing-ribbon underlay stays
   muted there. */
@media (max-width: 768px) {
  .install-edge-left .install-edge-bar,
  .install-edge-right .install-edge-bar,
  .install-edge-left .install-edge-label,
  .install-edge-right .install-edge-label {
    opacity: 1;
  }
}

/* When no LoRA is enabled in the corresponding slot, the perimeter bar
   collapses to a thin static line so it doesn't shout for attention. */
.install-edge-empty .install-edge-bar { transform: scaleY(0.05); opacity: 0.3; }
.install-edge-empty .install-edge-label { opacity: 0.4; }

/* First-run microcopy that teaches new users the Remix Strength strings
   are draggable. Lives inside .desktop-edge-drag[data-side="top"], follows
   the cursor's x-position via `left: <pct>%`, vertically centred via
   `transform: translate(-50%, …)`. The transform's translateY carries the
   tiny idle bob; horizontal positioning is parent-relative on `left` so
   the percentage resolves against the wrapper, not the element.
   `top: calc(100% + 6px)` puts the hint just *below* the slider's bar so
   it appears under the ribbons (not over them). Dismissed forever after
   the first successful drag (localStorage key: remix-strength:dismissed). */
.remix-hint {
  position: absolute;
  top: calc(100% + 6px);
  pointer-events: none;
  font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
  font-size: 11px;
  letter-spacing: 0.18em;
  color: rgba(255, 255, 255, 0.55);
  white-space: nowrap;
  text-transform: lowercase;
  transition: opacity 180ms ease;
  z-index: 8;
}

/* Prominent variant: applied to the top edge ribbon's hint while the
   per-song "drag to start" gate is active, so it reads as the next
   action a first-time user must take — not a passive label. Larger
   type, brighter color, and a soft glow only (single text-shadow,
   no filter chain — see PERFORMANCE.md). */
.remix-hint--prominent {
  font-size: 15px;
  letter-spacing: 0.22em;
  font-weight: 600;
  color: rgba(255, 255, 255, 0.92);
  text-shadow:
    0 0 6px rgba(255, 255, 255, 0.45),
    0 0 14px rgba(255, 255, 255, 0.25);
}

/* Desktop drag overlays — transparent click targets sized to each HUD
   edge bar. Pointer-events fall through the SVG ribbons (which set
   pointer-events: none) so dragging anywhere along the bar updates the
   bound parameter. data-empty="true" disables the overlay when there's
   no LoRA bound to that slot. */
.desktop-edge-drag {
  position: fixed;
  touch-action: none;
  z-index: 7; /* above .install-edge */
}
/* Click target spans the host AND the inward ribbon bleed so a user can
   grab anywhere on the visible ribbon — including the writhing curls
   that extend past the host into the central gutter. */
.desktop-edge-drag[data-side="top"] {
  top: 0;
  /* Match .install-edge-top's shortened insets on BOTH sides so the
     drag rect aligns with the visible ribbon at all fractions. With no
     left-corner anchor mounted, the left side simply has a blank
     reserved slot — drag still works between the two terminators. */
  left: calc(var(--hud-thickness) + 144px);
  right: calc(var(--hud-thickness) + 80px);
  height: calc(var(--hud-thickness) + var(--ribbon-bleed));
  cursor: ew-resize;
}
/* Match .install-edge-left/.install-edge-right's bottom inset (hud + 144px,
   leaving room for the turntable) so the drag rect's bottom aligns with the
   visible ribbon's bottom — cursor at the ribbon's bottom = fraction 0 = value
   0, instead of being ~24% up the rect because the drag extended into invisible
   space below the ribbon.

   Width extends past the ribbon (hud + bleed) into the gutter by
   --remix-hint-extend so the RemixHint can sit visibly inward — far
   enough from the screen edge that the ← / → arrow has room to "point
   at" the ribbon — while still inside the click area, so accidental
   clicks on the hint area register as a slider drag (not as clicks on
   whatever sits in the central gutter). */
:root {
  --remix-hint-extend: 60px;
}
.desktop-edge-drag[data-side="left"] {
  left: 0;
  top: var(--hud-thickness);
  bottom: calc(var(--hud-thickness) + 144px);
  width: calc(
    var(--hud-thickness) + var(--ribbon-bleed) + var(--remix-hint-extend)
  );
  cursor: ns-resize;
}
.desktop-edge-drag[data-side="right"] {
  right: 0;
  top: var(--hud-thickness);
  bottom: calc(var(--hud-thickness) + 144px);
  width: calc(
    var(--hud-thickness) + var(--ribbon-bleed) + var(--remix-hint-extend)
  );
  cursor: ns-resize;
}
/* Empty LoRA slot — keep the overlay on the page so the RemixHint stays
   visible at the head position, but signal non-interactivity with the
   default cursor. Pointer events stay enabled so hover-state still tracks;
   the pointerdown handler in DesktopEdgeDrag early-returns on
   data-empty="true", and commitValue no-ops without a bound LoRA id. */
.desktop-edge-drag[data-empty="true"] {
  cursor: default;
}

/* The bottom HUD edge has been replaced by the Advanced drawer — the
   drawer's handle bar lives at the bottom of the viewport now and serves
   the same visual role. No `.install-edge-bottom` rule needed. */

/* ============================================================================
   LoRA Library tile.  Two stacked sections:
     ── Active rack ──  enabled LoRAs only, each with a strength slider.
     ── Browse ──       every LoRA in a collapsible genre accordion;
                        compact click-to-enable rows, inline description.
   Each section scrolls internally so the Advanced drawer keeps its
   natural height.  Width is bounded to sit alongside the other tiles.
   ============================================================================ */
.mixer-tile[data-tile="library"] {
  min-width: 280px;
  max-width: 360px;
}

.lora-search {
  padding: 6px 8px 2px;
}
.lora-search-input {
  width: 100%;
  box-sizing: border-box;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--frame-line);
  border-radius: 5px;
  color: var(--text);
  font-family: inherit;
  font-size: 11px;
  letter-spacing: 0.02em;
  padding: 5px 8px;
}
.lora-search-input:focus {
  outline: none;
  border-color: var(--accent-line);
}
.lora-search-input::placeholder {
  color: var(--text-dim);
}

.lora-section-head {
  font-size: 9px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-dim);
  padding: 8px 10px 3px;
}

.mixer-tile[data-tile="library"] .lora-empty {
  font-size: 11px; opacity: 0.55; padding: 14px 6px;
  text-align: center; font-style: italic;
}

/* Internal scroll surfaces (active rack + browse). Subtle scrollbar so
   it doesn't shout. */
.lora-active-list,
.lora-browse {
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
}
.lora-active-list::-webkit-scrollbar,
.lora-browse::-webkit-scrollbar { width: 6px; }
.lora-active-list::-webkit-scrollbar-thumb,
.lora-browse::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.2); border-radius: 3px;
}
.lora-active-list::-webkit-scrollbar-thumb:hover,
.lora-browse::-webkit-scrollbar-thumb:hover {
  background: rgba(255, 255, 255, 0.35);
}

/* ── Active rack ── */
.lora-active-list {
  display: flex;
  flex-direction: column;
  gap: 5px;
  padding: 2px 8px 4px;
  max-height: 168px;
}
.lora-active-row {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  column-gap: 8px;
  row-gap: 2px;
  padding: 5px 8px 7px;
  color: var(--accent);
  background: rgba(240, 138, 72, 0.07);
  border: 1px solid var(--accent-line);
  border-radius: 6px;
}
.lora-active-name {
  font-size: 11px;
  letter-spacing: 0.04em;
  color: var(--text-strong);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.lora-active-remove {
  width: 16px; height: 16px;
  padding: 0;
  display: flex; align-items: center; justify-content: center;
  background: transparent;
  border: none;
  border-radius: 50%;
  color: var(--text-dim);
  font-size: 10px;
  cursor: pointer;
  transition: color 0.12s ease, background 0.12s ease;
}
.lora-active-remove:hover {
  color: var(--text-strong);
  background: rgba(255, 255, 255, 0.08);
}

/* ── Browse accordion ── */
.lora-browse {
  display: flex;
  flex-direction: column;
  padding: 0 8px 6px;
  max-height: 256px;
}
.lora-genre-head {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 7px 4px;
  background: transparent;
  border: none;
  border-bottom: 1px solid var(--frame-line);
  cursor: pointer;
  color: var(--text);
  transition: color 0.12s ease;
}
.lora-genre-head:hover { color: var(--text-strong); }
.lora-genre-caret {
  width: 9px;
  flex: none;
  color: var(--text-dim);
  font-size: 9px;
}
.lora-genre-name {
  flex: 1;
  text-align: left;
  font-size: 10px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
}
.lora-genre-count {
  color: var(--text-dim);
  font-size: 10px;
  font-variant-numeric: tabular-nums;
  min-width: 2ch;
  text-align: right;
}
.lora-genre-on {
  color: var(--accent);
  font-size: 9px;
  letter-spacing: 0.03em;
  border: 1px solid var(--accent-line);
  border-radius: 8px;
  padding: 1px 6px;
}
.lora-genre-body {
  display: flex;
  flex-direction: column;
  padding: 3px 0 7px;
}

.lora-browse-row {
  display: flex;
  flex-direction: column;
  gap: 2px;
  width: 100%;
  text-align: left;
  background: transparent;
  border: none;
  border-radius: 5px;
  padding: 5px 7px;
  cursor: pointer;
  transition: background 0.12s ease;
}
.lora-browse-row:hover { background: rgba(255, 255, 255, 0.04); }
.lora-browse-row.enabled { background: rgba(240, 138, 72, 0.09); }
.lora-browse-main {
  display: flex;
  align-items: center;
  gap: 8px;
}
.lora-browse-name {
  flex: 1;
  font-size: 11px;
  letter-spacing: 0.04em;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.lora-browse-row.enabled .lora-browse-name { color: var(--text-strong); }
.lora-browse-add {
  width: 16px; height: 16px;
  flex: none;
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%;
  border: 1px solid var(--frame-line);
  color: var(--text-dim);
  font-size: 11px;
  line-height: 1;
  transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.lora-browse-row:hover .lora-browse-add {
  border-color: var(--accent-line);
  color: var(--text);
}
.lora-browse-row.enabled .lora-browse-add {
  background: var(--accent);
  border-color: var(--accent);
  color: #ffffff;
}
/* Cap-blocked rows look reachable but inert. The native :disabled
 * already filters pointer events; we just dim and tint the "—" badge
 * so the operator's eye understands "you can interact, but only by
 * freeing a slot first" — the tooltip carries the verbal explanation.
 * Enabled rows are never cap-blocked (toggling them OFF is allowed),
 * so the .enabled rule above takes precedence on visited slots. */
.lora-browse-row.cap-blocked {
  opacity: 0.45;
  cursor: not-allowed;
}
.lora-browse-row.cap-blocked .lora-browse-add {
  color: var(--text-dim);
  border-color: var(--border-dim);
}
.lora-browse-desc {
  font-size: 10px;
  line-height: 1.35;
  color: var(--text-dim);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ── Context menu (right-click a row) ── */
.lora-context-menu {
  position: fixed;
  z-index: 200;
  min-width: 150px;
  padding: 4px;
  background: var(--panel, #16161f);
  border: 1px solid var(--frame-line);
  border-radius: 6px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.lora-context-menu-item {
  display: block;
  width: 100%;
  text-align: left;
  background: transparent;
  border: none;
  border-radius: 4px;
  color: var(--text);
  font-family: inherit;
  font-size: 11px;
  padding: 6px 8px;
  cursor: pointer;
}
.lora-context-menu-item:hover {
  background: rgba(255, 255, 255, 0.07);
  color: var(--text-strong);
}
.lora-context-menu-divider {
  height: 1px;
  margin: 4px 6px;
  background: var(--frame-line);
  opacity: 0.85;
}

/* ── Hidden-LoRA footer ── */
.lora-hidden-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 6px 10px;
  border-top: 1px solid var(--frame-line);
  font-size: 10px;
  color: var(--text-dim);
}
.lora-hidden-toggle {
  background: transparent;
  border: none;
  color: var(--accent);
  font-family: inherit;
  font-size: 10px;
  cursor: pointer;
  padding: 0;
}
.lora-hidden-toggle:hover { color: var(--accent-hover, var(--accent)); }

/* Horizontal strength slider.  Spans the full active-row width below
   the name line, giving the operator a wide drag surface without
   making each row tall. */
.lora-strength {
  grid-column: 1 / -1;
  display: flex; align-items: center; gap: 8px;
}
.lora-strength-track {
  position: relative;
  flex: 1;
  height: 6px;
  background: rgba(255, 255, 255, 0.08);
  border-radius: 3px;
  cursor: pointer;
  transition: background 0.12s ease;
  /* Vertical room for the 14px thumb knob without growing the row.
     The track itself stays 6px tall; the thumb floats above and below. */
  margin: 4px 7px;
  /* The slider lives inside the MobileFullSheet, whose .mobile-sheet-track
   * uses ``scroll-snap-type: x mandatory`` to swipe between tabs. Without
   * ``touch-action: none``, a horizontal drag on the slider is interpreted
   * by the browser as a scroll gesture on the parent — the sheet snaps to
   * the next tab instead of moving the strength value. ``none`` tells the
   * browser "this element handles all touch gestures itself", so the
   * dispatcher's pointer-capture wins and the sheet stays put. */
  touch-action: none;
}
.lora-strength-track:hover { background: rgba(255, 255, 255, 0.14); }
.lora-strength-fill {
  position: absolute;
  top: 0; bottom: 0; left: 0;
  width: 0%;
  background: currentColor;
  opacity: 0.75;
  border-radius: 3px;
  transition: width 0.06s linear;
}
/* Visible draggable handle. Sits centered on the fill head so the
   user has an obvious grab target — earlier the track was 4px tall
   with no thumb, and operators reported the drag affordance was
   easy to miss. Pointer events stay on the track so the handle
   inherits the same pointermove pipeline. */
.lora-strength-thumb {
  position: absolute;
  top: 50%;
  width: 14px;
  height: 14px;
  margin-left: -7px;
  margin-top: -7px;
  border-radius: 50%;
  background: var(--text-strong);
  border: 2px solid currentColor;
  opacity: 0.95;
  pointer-events: none;
  transition: left 0.06s linear, transform 0.12s ease, box-shadow 0.18s ease;
}
.lora-strength-track:hover .lora-strength-thumb {
  transform: scale(1.15);
  box-shadow: 0 0 8px var(--accent-glow);
}
.lora-strength-value {
  font-variant-numeric: tabular-nums;
  font-size: 10px;
  min-width: 3ch;
  text-align: right;
  opacity: 0.7;
}

/* Refit-pending lock state. After the dispatcher commits a strength
 * change (DEBOUNCE_MS after the last pointermove), the LoRA's slider
 * locks for REFIT_LOCK_MS while the server's TRT refit settles —
 * rapid commits during that window would queue refits the pipeline
 * can't service in real time, resulting in slider movements that feel
 * disconnected from audible output. The track-fill keeps animating;
 * only input is gated.
 *
 * Input blocking is done in JS — every pointer entry point
 * (ActiveLoraRow, DesktopEdgeDrag, useLoraFaderDrag) calls
 * dispatcher.isLocked(id) on pointerdown and early-returns. The CSS
 * here is the visual layer only: cursor:progress gives the standard
 * "system is working" cue, and opacity dims the thumb/value so the
 * row reads as inactive without obscuring the fill value the operator
 * is waiting on. Deliberately NOT pointer-events:none on the track —
 * that would also kill the title-attribute tooltip and cursor:progress
 * itself on the track element. */
.lora-active-row.refit-pending .lora-strength-track {
  cursor: progress;
}
.lora-active-row.refit-pending .lora-strength-thumb {
  opacity: 0.55;
  transform: none;
  box-shadow: none;
}
.lora-active-row.refit-pending .lora-strength-value {
  opacity: 0.45;
}

/* ============================================================================
   Buttons — single shared "outline + accent on hover" language. The Seed /
   Pause / Send buttons inside the drawer all share this look so the whole
   UI reads as one piece. The drawer handle is its own variant (see below).
   ============================================================================ */
.seed-btn,
.pause-btn,
.send-prompt-btn {
  background: transparent;
  color: var(--text-dim);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  font: inherit;
  font-family: var(--font-mono);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  cursor: pointer;
  transition: color 120ms, border-color 120ms, background 120ms;
}
.seed-btn:hover,
.pause-btn:hover,
.send-prompt-btn:hover {
  color: var(--accent);
  border-color: var(--accent);
  background: var(--accent-soft);
}
.seed-btn:active,
.send-prompt-btn:active,
.send-prompt-btn.is-pressed,
.pause-btn:active {
  background: var(--accent-medium);
  border-color: var(--accent);
}

.pause-btn {
  /* WCAG 2.5.5 / iOS HIG touch target floor. */
  min-width: 44px;
  min-height: 44px;
  padding: 0 10px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 0.85em;
  letter-spacing: 0;
}
.pause-btn.active {
  background: var(--accent-soft);
  border-color: var(--accent);
  color: var(--text);
}

.seed-btn {
  width: 60px;
  height: 90px;
  font-size: 1.3em;
  letter-spacing: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Inline SVG dice replaces the OS-rendered emoji 🎲 — sized to feel
   commensurate with the button (~28px) and inherits color from the
   button so hover/active states tint the icon. Used in both SeedTile
   (advanced drawer) and LiteControls (mobile/lite). */
.seed-dice {
  width: 28px;
  height: 28px;
  color: var(--text);
}
.lite-seed-icon .seed-dice {
  width: 22px;
  height: 22px;
}

.send-prompt-btn {
  padding: 10px 16px;
  /* WCAG 2.5.5 / iOS HIG touch target floor. */
  min-height: 44px;
  min-width: 44px;
  font-size: 0.72em;
  letter-spacing: 0.06em;
}
#prompt-section .send-prompt-btn {
  width: 100%;
  align-self: stretch;
}

/* ============================================================================
   Full Controls panel — LEFT inset floating panel (Wave 9). Same chrome
   vocabulary as the HeroMacros bay (recessed bg + frame-line + blur),
   sized to leave headroom above the hero bay and below the top HUD.
   ============================================================================ */
:root {
  /* Drawer handle is hidden — HeroMacros caret toggles the panel. */
  --drawer-handle-h: 0px;
  --drawer-h: clamp(380px, 50vh, 540px);
  --drawer-w: clamp(360px, 32vw, 440px);
  /* Live "is the panel open" inset value. The panel is on the LEFT
     now (was right pre-Wave-9), so LEFT-anchored elements that need
     to shift right when it opens read this — currently just the
     centered HeroMacros bay. Right-anchored chrome does NOT need to
     slide; those references were dropped in Wave 9. */
  --drawer-l-open: 0px;
  /* Back-compat alias: any historical `--drawer-w-open` callers map
     to the same 0 value when no opening is active. */
  --drawer-w-open: 0px;
}
body.drawer-open {
  --drawer-l-open: var(--drawer-w);
}
@media (max-width: 768px) {
  body.drawer-open {
    --drawer-l-open: 0px;
  }
}

/* Desktop (> 768px): the Full Controls panel is an INSET floating
   panel on the LEFT — same chrome vocabulary as the HeroMacros bay.
   Mobile keeps the bottom-sheet pattern. */
.install-sheet {
  position: fixed;
  top: max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-top, 0px));
  left: max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-left, 0px));
  bottom: max(calc(var(--hud-thickness) + 24px + 112px), env(safe-area-inset-bottom, 0px));
  width: var(--drawer-w);
  background: rgba(6, 6, 12, 0.62);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.04),
    0 4px 14px rgba(0, 0, 0, 0.4);
  -webkit-backdrop-filter: blur(8px);
          backdrop-filter: blur(8px);
  /* Closed: slide left so only the 32px edge handle is visible, with
     its right edge at screen x=32. Math: panel_left = hud+24, so
     translate = 32 - (hud+24) - width = -hud - width + 8, which is
     `-100% - hud + 8px`. */
  transform: translateX(calc(-100% - var(--hud-thickness) + 8px));
  transition: transform 320ms cubic-bezier(.2, .7, .2, 1);
  z-index: 50;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
@media (max-width: 768px) {
  .install-sheet {
    /* Mobile fallback — bottom-anchored sheet, as it was. */
    top: auto;
    left: 0;
    right: 0;
    bottom: 0;
    width: auto;
    height: var(--drawer-h);
    background: var(--sheet-bg);
    border: none;
    border-top: 1px solid var(--frame-line);
    border-radius: 0;
    box-shadow: none;
    -webkit-backdrop-filter: none;
            backdrop-filter: none;
    transform: translateY(100%);
  }
}
.install-sheet.open { transform: translateX(0); }
@media (max-width: 768px) {
  .install-sheet.open { transform: translateY(0); }
}

/* Spread mode — widen the panel so all six tab bodies fit as a
   responsive grid (see .mixer-rack--spread below). The closed-state
   transform (`-100% - hud + 8`) automatically preserves the 32px peek
   regardless of width, so opening still slides cleanly to translateX(0). */
.install-sheet--spread {
  width: min(1400px, calc(100vw - 2 * (var(--hud-thickness) + 24px)));
  /* Reclaim vertical real estate: the default `bottom` reserves 112px
     for HeroMacros visibility, but in spread mode we want every pixel
     to land in the grid — the user closes the drawer when they want
     HeroMacros back. */
  bottom: max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-bottom, 0px));
}
/* The persistent help readout adds 88–28vh at the bottom of every
   drawer body. In spread mode the static block would eat the grid
   height the layout exists to maximise. DrawerHelpBar instead renders
   only while actively hovering (returns null when idle) and adds the
   `--overlay` modifier below, so the bar floats over the bottom of
   the panel instead of pushing the grid up. */
@media (max-width: 768px) {
  .install-sheet--spread { width: auto; }
}

/* Edge handle on the right of the panel — visible peek-strip when the
   panel is closed so the user reads it as a drawer affordance, not a
   cutoff bug. Sits inside the panel's bounding box so the panel's
   chrome (frame-line + radius) frames it on close. Click toggles. */
.install-sheet-edge-handle {
  position: absolute;
  top: 0; bottom: 0; right: 0;
  width: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Opaque-enough bg so the panel's tabs/content behind don't bleed
     through when only the handle is visible in the closed state. */
  background: rgba(8, 8, 14, 0.96);
  border: none;
  border-left: 1px solid var(--frame-line);
  border-radius: 0 var(--radius-md) var(--radius-md) 0;
  cursor: pointer;
  color: var(--text-dim);
  font-size: 22px;
  line-height: 1;
  -webkit-appearance: none;
          appearance: none;
  transition: color 140ms ease-out, background 140ms ease-out;
  z-index: 2;
}
.install-sheet-edge-handle:hover {
  color: var(--text-strong);
  background: rgba(20, 20, 30, 0.96);
}
.install-sheet-edge-handle-caret {
  display: inline-block;
}
@media (max-width: 768px) {
  .install-sheet-edge-handle { display: none; }
}

/* Tiny square toggle pinned to the bottom of the edge handle. Switches
   the drawer between tabbed and spread (all-controls) layouts. Sits
   inside the 32px handle column so it reads as part of the handle, not
   a floating chip. Always visible (even when closed) so the operator can
   open directly into the expanded layout. */
.install-sheet-spread-toggle {
  position: absolute;
  right: 5px;
  bottom: 8px;
  width: 22px;
  height: 22px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: rgba(0, 0, 0, 0.45);
  border: 1px solid var(--frame-line);
  border-radius: 3px;
  color: var(--text-dim);
  cursor: pointer;
  -webkit-appearance: none;
          appearance: none;
  transition: color 140ms ease-out, background 140ms ease-out, border-color 140ms ease-out;
  z-index: 3;
}
.install-sheet-spread-toggle:hover {
  color: var(--text-strong);
  background: rgba(20, 20, 30, 0.9);
  border-color: var(--accent-line);
}
.install-sheet-spread-toggle[aria-pressed="true"] {
  color: var(--accent);
  border-color: var(--accent-line);
}
@media (max-width: 768px) {
  .install-sheet-spread-toggle { display: none; }
}

/* When the panel is open, the body content takes the full width so
   the topbar/tabs fit. The edge handle still sits on the right inside
   the panel; user can click it to close. */
.install-sheet.open .install-sheet-body {
  padding-right: 36px;
}

/* DEMON brand wordmark — top-left corner of the performance scene.
   Five SVG letters ticked by useRenderLoop → tickDemonLetters with the
   live kick value, so the writhe amplitude breathes with the audio in
   the same language as the halo / perimeter ribbons. Stroke width +
   alpha consume --bloom-amount (set on #performance by useRenderLoop)
   so transients widen and brighten the strokes without per-frame JS.
   pointer-events: none so the brand mark never eats clicks.

   The brand mark sits ABOVE the StartOverlay backdrop in z-stacking, so
   when StartOverlay.handleClick fades that backdrop, the letters reveal
   in their final layout positions. The entrance animation (D/E/M/N
   sliding in from the left, O cross-fading in) is gated on the
   --entered modifier, which DemonBrandMark adds when the session
   leaves "idle" — i.e. the same click that triggers the halo morph. */
.demon-brand-mark {
  position: absolute;
  top: 18px;
  left: 20px;
  /* Above #start-overlay (z:20) so the wordmark is visible the moment
     the overlay backdrop fades to transparent during the launch morph.
     Below modals (z:100+) and the cursor (z:111). */
  z-index: 30;
  display: flex;
  align-items: center;
  gap: 5px;
  pointer-events: none;
}
.demon-letter {
  width: 32px;
  height: 42px;
  overflow: visible;
  filter: drop-shadow(0 0 6px rgba(255, 255, 255, calc(0.28 + var(--bloom-amount, 0) * 0.35)));
}
.demon-letter path {
  fill: none;
  stroke-width: calc(1.6px + var(--bloom-amount, 0) * 2.2px);
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: calc(0.78 + var(--bloom-amount, 0) * 0.22);
  vector-effect: non-scaling-stroke;
}

/* Idle state: every letter is invisible. The letters keep their resting
   layout positions (no transform) so StartOverlay.handleClick can
   measure the O letter's true final rect when wiring up the FLIP
   morph — the entrance animation supplies its offset transform via
   @keyframes only, never as a baseline. */
.demon-brand-mark .demon-letter { opacity: 0; }
/* The O is the slot the halo morphs into, so hide it via visibility
   (not opacity) — that way the halo→O hand-off has zero opacity
   transitions anywhere in the chain, even an instant steps(1) one. */
.demon-brand-mark .demon-letter[data-letter="O"] {
  opacity: 1;
  visibility: hidden;
}

/* Entrance — fires once when the session leaves idle. D/E/M/N slide in
   from the left with staggered delays. O is the special case: it must
   NOT cross-fade — the halo lands fully opaque at the O slot, then we
   instantly swap to the fixed letter. We do that with a steps(1) jump
   timed to land a frame before the overlay snaps to display:none, so
   the brand mark (z:30, above the overlay z:20) covers the halo's
   final resting position for one paint before the halo is removed.
   D/E/M/N animations complete well inside the 1300ms LAUNCH_DURATION_MS
   so the overlay's display:none snap doesn't catch any letter mid-anim. */
.demon-brand-mark--entered .demon-letter {
  animation: demon-letter-slide-in 460ms cubic-bezier(.2, .8, .2, 1) forwards;
}
.demon-brand-mark--entered .demon-letter[data-letter="D"] { animation-delay: 220ms; }
.demon-brand-mark--entered .demon-letter[data-letter="E"] { animation-delay: 300ms; }
.demon-brand-mark--entered .demon-letter[data-letter="M"] { animation-delay: 380ms; }
.demon-brand-mark--entered .demon-letter[data-letter="N"] { animation-delay: 460ms; }
.demon-brand-mark--entered .demon-letter[data-letter="O"] {
  /* steps(1, end): holds visibility:hidden for the full duration, then
     jumps to visibility:visible at the end. Lands at t≈1280ms — a hair
     before the overlay vanishes at LAUNCH_DURATION_MS (1300ms), so the
     swap is seamless. No opacity property touched anywhere. */
  animation: demon-letter-o-snap 1280ms steps(1, end) forwards;
}

@keyframes demon-letter-slide-in {
  0%   { opacity: 0; transform: translateX(-18px); }
  100% { opacity: 1; transform: translateX(0); }
}
@keyframes demon-letter-o-snap {
  from { visibility: hidden; }
  to   { visibility: visible; }
}

/* Drawer body — vertical stack: operator strip across the top, then a
   wrap-flex grid of mixer tiles below. Tiles wrap to the next row before
   anything overflows horizontally — no sideways scrolling. */
.install-sheet-body {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px 14px 14px;
  min-height: 0;
}

/* Persistent help readout pinned to the bottom of the panel. Reflects
   the `data-dd-tooltip` text of whatever the user is hovering. Pulls
   the same sheet vocabulary (frame-line border-top, dim text) as the
   advisory boxes elsewhere in the HUD. Capped at roughly 1/4 of the
   typical panel height — gives long descriptions enough room without
   crowding the controls above. */
.drawer-help-bar {
  flex: 0 0 auto;
  margin-top: auto;
  /* Fixed height, NOT content-driven. A band that grew/shrank with the
     hovered control's description length would resize the control grid
     above it on every hover — a constant stutter. Keep it constant and
     let a rare long description scroll inside (overflow-y below). */
  height: 92px;
  padding: 10px 12px 11px;
  border-top: 1px solid var(--frame-line);
  background: rgba(8, 8, 14, 0.55);
  border-radius: 0 0 var(--radius-md) var(--radius-md);
  display: flex;
  flex-direction: column;
  gap: 4px;
  overflow-y: auto;
}
/* Spread (all-controls) layout is far wider, so descriptions wrap to
   one or two lines instead of the three-plus the narrow tabbed panel
   needs. Reserve a shorter fixed band there so the help strip doesn't
   eat grid height it no longer needs. */
.install-sheet--spread .drawer-help-bar {
  height: 58px;
}
.drawer-help-bar--active {
  background: rgba(12, 12, 20, 0.7);
}
.drawer-help-bar-title {
  font-family: var(--font-mono);
  font-size: 0.6em;
  letter-spacing: var(--tracking-bar);
  text-transform: uppercase;
  color: var(--accent);
  margin: 0;
}
.drawer-help-bar-text {
  font-size: 11px;
  line-height: 1.5;
  color: var(--text-strong);
  margin: 0;
}
.drawer-help-bar-hint {
  font-size: 10.5px;
  line-height: 1.4;
  color: var(--text-dim);
  font-style: italic;
  margin: 0;
}
@media (max-width: 768px) {
  /* No drawer on mobile — the help bar lives in the desktop install
     sheet only. Hide just in case the desktop branch gets mounted. */
  .drawer-help-bar { display: none; }
}

/* Ambient hover readout used when the Full Controls panel is closed.
   A slim floating chip pinned to the top-center of the viewport, just
   below the top HUD edge bar — out of the way of the hero macros bay
   and graph, but always in peripheral vision. Hidden whenever the
   drawer is open since the drawer's own help bar takes over there. */
.hud-help-readout {
  position: fixed;
  /* Sits in the header band — aligned with the halo badge — so the
     readout floats ABOVE the waveform scrub strip
     (top: hud-thickness + 24px) instead of landing on top of it. */
  top: max(
    calc(var(--hud-thickness) / 2 - 10px + var(--top-bias, 5px)),
    env(safe-area-inset-top, 0px)
  );
  left: 50%;
  transform: translateX(-50%);
  z-index: 25;
  pointer-events: none;
  max-width: min(540px, 70vw);
  padding: 5px 13px 6px;
  /* Quiet chip — light translucent fill + blur, no gradient border or
     drop-shadow (those read as "too much" against the header chrome). */
  background: rgba(10, 10, 16, 0.6);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  -webkit-backdrop-filter: blur(8px);
          backdrop-filter: blur(8px);
  display: flex;
  flex-direction: column;
  gap: 2px;
}
body.drawer-open .hud-help-readout {
  display: none;
}
.hud-help-readout-title {
  font-family: var(--font-mono);
  font-size: 0.55em;
  letter-spacing: var(--tracking-bar);
  text-transform: uppercase;
  color: var(--accent);
  margin: 0;
}
.hud-help-readout-text {
  font-size: 10.5px;
  line-height: 1.45;
  color: var(--text-strong);
  margin: 0;
}
@media (max-width: 768px) {
  .hud-help-readout { display: none; }
}

.install-section-operator {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  padding-bottom: 10px;
  border-bottom: 1px solid var(--frame-line);
}

/* ── Operator strip — grouped sections ───────────────────────────
   The CONFIG tab body. Sections labeled with mono-caps headers,
   thin frame-line divider between groups. Each section's controls
   flow horizontally inside an `.operator-row`. */
.operator-strip {
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding: 4px 2px 6px;
}
.operator-section {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.operator-section + .operator-section {
  border-top: 1px solid var(--frame-line);
  padding-top: 12px;
}
.operator-section-label {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 0.6em;
  letter-spacing: var(--tracking-bar);
  text-transform: uppercase;
  color: var(--text-dim);
  font-weight: normal;
}
.operator-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}
/* Visually hidden on desktop rather than display:none, so the
   aria-describedby targets stay in the accessibility tree (the visible
   affordance on desktop is the hover tooltip). The mobile sheet promotes
   this to a real visible readout below the buttons. */
.operator-config-readout {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.install-section-operator .ws-url-input { width: 220px; }
/* Density toggle anchors the right cluster — its margin-left: auto pushes
   itself plus the trailing pause button to the far right of the row. */
.install-section-operator .operator-density-toggle { margin-left: auto; }
.install-section-operator .pause-btn--right { margin-left: 0; }

/* The mixer-rack lays out one row of all 7 tiles (Main · Engine ·
   Channel Gains · Channels · DCW · Library · Seed) plus a full-width
   Prompts row beneath. Each tile is content-sized; the row stays on
   one line and scrolls horizontally if the viewport is too narrow.
   At ≤1500px (laptop screens) the @media block below auto-shrinks
   the per-strip width and tile padding so the row fits without
   scrolling. */
.mixer-rack {
  display: flex;
  flex-direction: column;
  gap: 10px;
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}
.mixer-rack-row {
  display: flex;
  /* Tiles keep their content-driven widths and stay on a single line
     until the viewport drops below the compact floor (~1500px), at
     which point they wrap onto a second line — wrapping is preferred
     over horizontal scroll, always. The fluid clamp on
     `--slider-strip-w` already keeps the row fitting between 1500px
     and 2400px without wrapping. */
  flex-wrap: wrap;
  gap: 8px;
  align-items: stretch;
  flex: 0 0 auto;
}
/* Density scales fluidly with viewport width — no hard breakpoint, no
   abrupt mode flip. The row's total content at standard density
   (50px slider strips, 8/10/10 padding) measures ~1800px; on
   anything narrower, the strips and padding interpolate down to
   compact (36px / 6/7/7) so the rack always fits without horizontal
   scroll. Above ~2400px viewport, full standard density takes over;
   below ~1500px, full compact. In between (every laptop screen
   anywhere), the controls smoothly thicken as room appears. */
.mixer-rack {
  --slider-strip-w: clamp(
    36px,
    calc(36px + (100vw - 1500px) * 14 / 900),
    50px
  );
}
.mixer-rack .mixer-tile {
  padding:
    clamp(6px, calc(6px + (100vw - 1500px) * 2 / 900), 8px)
    clamp(7px, calc(7px + (100vw - 1500px) * 3 / 900), 10px)
    clamp(7px, calc(7px + (100vw - 1500px) * 3 / 900), 10px);
  gap: clamp(4px, calc(4px + (100vw - 1500px) * 2 / 900), 6px);
}
.mixer-rack .mixer-channels {
  gap: clamp(3px, calc(3px + (100vw - 1500px) * 1 / 900), 4px);
}
.mixer-rack .dcw-panel {
  width: clamp(110px, calc(110px + (100vw - 1500px) * 20 / 900), 130px);
}

/* Keyboard-shortcut hints — toggled independently of density. The
   class lives on .mixer-rack so it covers every kbd inside the drawer
   (slider hints, blend hint, send-prompt hint, seed F hint). The chained
   `.mixer-rack` selector boosts specificity above the per-kbd rules
   below (.slider-group kbd, .blend-kbd, .send-kbd) which would otherwise
   win by source order. */
.mixer-rack.mixer-rack--no-kbd-hints kbd,
.mixer-rack.mixer-rack--no-kbd-hints .blend-kbd,
.mixer-rack.mixer-rack--no-kbd-hints .send-kbd {
  display: none;
}

/* ── Tabbed drawer ──────────────────────────────────────────────────
   Modeled on the MAIN / GRAINS / FILTER / ... header pattern from
   Sound Particles GrainDust. The mixer-rack--tabbed body shows ONE
   tile at a time (the active tab's contents) rather than a row of
   every tile. The single tile fills the rack width so its sliders
   spread out instead of cramming. */
.install-sheet-topbar {
  display: flex;
  align-items: stretch;
  border-bottom: 1px solid var(--frame-line);
  margin-bottom: 6px;
}
/* Segmented hardware tab strip — one bordered shell containing
   beveled cells. Each cell has a monoline icon above its label.
   Inactive cells sit FLUSH with the shell (subtle inner highlight on
   top to suggest a raised face). Active cell is RECESSED via inset
   shadow + brighter foreground, like pressing a hardware button. */
.drawer-tabs {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  gap: 3px;
  padding: 3px;
  margin: 4px 0 6px;
  /* Inset bay — same vocabulary as the HeroMacros bay, StylePanel,
     and slider grooves. The whole strip reads as one piece of
     hardware with cells inside, not six floating buttons. */
  background: rgba(0, 0, 0, 0.35);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  box-shadow:
    inset 0 1px 2px rgba(0, 0, 0, 0.5),
    inset 0 -1px 0 rgba(255, 255, 255, 0.03),
    0 1px 0 rgba(255, 255, 255, 0.04);
}
.drawer-tabs-row {
  display: flex;
  gap: 0;
}
.drawer-tab {
  display: inline-flex;
  flex: 1 1 0;
  min-width: 0;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  padding: 6px 2px 7px;
  background: linear-gradient(
    to bottom,
    rgba(255, 255, 255, 0.04) 0%,
    rgba(255, 255, 255, 0.01) 50%,
    rgba(0, 0, 0, 0.15) 100%
  );
  border: 1px solid transparent;
  border-radius: 3px;
  color: var(--text-dim);
  cursor: pointer;
  -webkit-appearance: none;
          appearance: none;
  font: inherit;
  /* Hairline separator between cells — drawn as a left border on all
     but the first cell. */
  margin-left: 1px;
  transition:
    color 140ms ease-out,
    background 140ms ease-out,
    box-shadow 140ms ease-out;
}
.drawer-tab:first-child {
  margin-left: 0;
}
.drawer-tab:hover {
  color: var(--text);
  background: linear-gradient(
    to bottom,
    rgba(255, 255, 255, 0.06) 0%,
    rgba(255, 255, 255, 0.02) 50%,
    rgba(0, 0, 0, 0.1) 100%
  );
}
.drawer-tab--active {
  /* Recessed cell — pressed-in feel. Inset shadow at the top + a
     subtle bottom highlight that sells the depth flip. Border picks
     up the accent line color so the active cell carves out from the
     strip without needing a chip color. */
  color: var(--text-strong);
  background:
    linear-gradient(
      to bottom,
      rgba(0, 0, 0, 0.35) 0%,
      rgba(0, 0, 0, 0.15) 50%,
      rgba(255, 255, 255, 0.04) 100%
    );
  border-color: rgba(240, 138, 72, 0.25);
  box-shadow:
    inset 0 1px 2px rgba(0, 0, 0, 0.55),
    inset 0 0 0 1px rgba(0, 0, 0, 0.4);
}
.drawer-tab--active .drawer-tab-icon {
  /* Icon picks up the accent so the recessed cell has a colored
     "light" the way a hardware button's LED comes on. */
  color: var(--accent);
}
.drawer-tab-icon {
  display: block;
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
  color: currentColor;
  transition: color 140ms ease-out, transform 140ms ease-out;
}
.drawer-tab:hover .drawer-tab-icon {
  transform: translateY(-0.5px);
}
.drawer-tab-label {
  font-family: var(--font-mono);
  font-size: 0.58em;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  line-height: 1;
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.mixer-rack--tabbed > .mixer-tile,
.mixer-rack--tabbed > .knob-tile {
  /* Active tab's body fills the rack instead of sitting in a row. The
     internal .mixer-channels still flexes its sliders edge-to-edge. */
  flex: 1 1 auto;
  width: 100%;
}
/* Styles tab — prompts on top, LoRA library underneath. Both panels
   are content-sized; if the combined height exceeds the panel, the
   parent `.mixer-rack` scrolls (overflow-y: auto already set above).
   `min-height: 0` on the column stack lets the scroll container do its
   job — without it, the legacy row-wrap min-height: 110px on
   .mixer-tile-prompts would force vertical slack below "Send Tags". */
.mixer-rack--tabbed > .styles-tab,
.mixer-rack--tabbed > .mod-tab {
  flex: 0 0 auto;
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-height: 0;
}
.styles-tab > .mixer-tile,
.mod-tab > .mixer-tile {
  flex: 0 0 auto;
  width: 100%;
  max-width: 100%;
  min-height: 0;
}
.styles-tab > * + *,
.mod-tab > * + * {
  border-top: 1px solid var(--frame-line);
  padding-top: 12px;
}

/* Styles-tab accordions — Tags + LoRA Library each collapse under a
   clickable header (CollapsibleTile). Closed by default. */
.styles-accordion {
  width: 100%;
  min-height: 0;
}
.styles-accordion-head {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 4px;
  background: transparent;
  border: none;
  cursor: pointer;
  color: var(--text);
  transition: color 0.12s ease;
}
.styles-accordion-head:hover { color: var(--text-strong); }
.styles-accordion-caret {
  width: 10px;
  flex: none;
  color: var(--text-dim);
  font-size: 10px;
}
.styles-accordion-title {
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-dim);
  transition: color 0.12s ease;
}
.styles-accordion.open .styles-accordion-title,
.styles-accordion-head:hover .styles-accordion-title {
  color: var(--text-strong);
}
.styles-accordion-body {
  padding-top: 2px;
}
/* Inside the accordion the header is the section label, so drop the
   wrapped tile's own label + card frame. */
.styles-accordion-body .mixer-tile {
  border: none;
  background: transparent;
  padding: 0;
  width: 100%;
  max-width: 100%;
}
.styles-accordion-body .mixer-tile-label { display: none; }
/* Drop the inner tile frame for tabbed content — the active tab is
   already the section delimiter; an extra card border just adds noise.
   Sound Particles' UIs do the same: panels live on the open canvas. */
.mixer-rack--tabbed > .mixer-tile,
.mixer-rack--tabbed > .knob-tile {
  border: none;
  background: transparent;
  padding: 8px 4px 4px;
}
.mixer-rack--tabbed > .mixer-tile > .mixer-tile-label {
  /* Section header (e.g. "Voice") still useful on the multi-row VOICE
     panel; CORE/MOD/PROMPT/LIB rely on the tab itself for labeling. */
}
.mixer-rack--tabbed[data-active-tab="core"] > .knob-tile > .knob-tile-label,
.mixer-rack--tabbed[data-active-tab="mod"] > .knob-tile > .knob-tile-label,
.mixer-rack--tabbed[data-active-tab="lib"] > .mixer-tile > .mixer-tile-label,
.mixer-rack--tabbed[data-active-tab="prompt"] > .mixer-tile > .mixer-tile-label {
  display: none;
}
.mixer-rack--tabbed[data-active-tab="prompt"] {
  /* Prompt tab stacks SeedTile + PromptsTile vertically. Both are
     content-sized so flex: 0 0 auto keeps them tight. */
  flex-direction: column;
  gap: 10px;
}
.mixer-rack--tabbed[data-active-tab="config"] > * {
  /* OperatorStrip lives inside the CONFIG tab now; let it fill. */
  flex: 1 1 auto;
  width: 100%;
}

/* ── Spread (all-controls) layout ─────────────────────────────────
   All six tab bodies live on screen at once. Fixed 3×2 grid that
   fills the panel's available height; each section scrolls
   internally if its content is taller than its cell, so the outer
   surface never scrolls. Same tile/knob internals as tabbed mode —
   only the container changes. */
.mixer-rack--spread {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
  /* Voice gets the extra width because 14 channel faders don't fit in
     a 1/3 column; everything else slots into a single cell. Saved is
     dropped from spread mode (see AdvancedDrawer.tsx). */
  grid-template-areas:
    "core   styles  mod"
    "voice  voice   config";
  align-items: stretch;
  gap: 8px;
  /* Override the base .mixer-rack overflow-y: auto — outer scroll is
     the thing we're trying to avoid here. */
  overflow: hidden;
  min-height: 0;
}
.spread-section[data-section="core"]   { grid-area: core; }
.spread-section[data-section="styles"] { grid-area: styles; }
.spread-section[data-section="mod"]    { grid-area: mod; }
.spread-section[data-section="voice"]  { grid-area: voice; }
.spread-section[data-section="config"] { grid-area: config; }
.spread-section {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
  min-height: 0;
  padding: 6px 8px 8px;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.18);
  /* Internal scroll if the section's tile is taller than the cell. */
  overflow-y: auto;
  overflow-x: hidden;
  /* Subtle scrollbar so individual sections that DO need to scroll
     don't shout. Matches the LoRA library tile's scrollbar treatment. */
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
}
.spread-section::-webkit-scrollbar { width: 6px; }
.spread-section::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.2); border-radius: 3px;
}
.spread-section-label {
  margin: 0;
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-size: 9px;
  font-weight: 500;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-dim);
  padding-bottom: 3px;
  border-bottom: 1px solid var(--frame-line);
}
/* Compact tile/knob padding inside spread cells — the cells are
   smaller than a full tabbed body, so the same defaults waste pixels
   on chrome. */
.spread-section .mixer-tile,
.spread-section .knob-tile {
  border: none;
  background: transparent;
  padding: 4px 2px 2px;
  gap: 4px;
}

/* ── Knob tile ──────────────────────────────────────────────────────
   Horizontal rack of rotary knobs. Used by CORE + MOD — continuous
   "tweak with one hand" params drawn as knobs (matches inShaper /
   GrainDust visual vocabulary). Reference-track pickers sit in a
   second row beneath the rack with breathing room. */
.knob-tile {
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.knob-rack {
  display: flex;
  flex-wrap: wrap;
  gap: 14px 18px;
  align-items: flex-start;
}
.knob-ref-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 14px;
}
.knob-dcw {
  margin-top: 4px;
}

/* CORE-tab stem layers — the same panners as the HeroMacros bay, stacked
   under the reference-track pickers. Own wrapper class (not
   .hero-macros-stems) so the `body.drawer-open .hero-macros-stems` hide
   rule doesn't catch this copy: it must stay visible precisely when the
   drawer is open. Reuses the .hero-stem-panner* widget styling. */
.core-stems {
  display: flex;
  flex-direction: column;
  gap: 4px;
  /* Same frame-line section divider the MOD / CONFIG / Voice tabs use to
     separate stacked control groups — sets the stems apart from the
     reference-track pickers above. */
  border-top: 1px solid var(--frame-line);
  padding-top: 12px;
}
.core-stems-label {
  font-family: var(--font-mono);
  font-size: 0.55em;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-dim);
}
.core-stems-status {
  font-family: var(--font-mono);
  font-size: 0.48em;
  font-style: italic;
  letter-spacing: 0.02em;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}
/* Descendant selector (not the bare .core-stem-panners class) so this
   wins over the later `.hero-stem-panners { width: 160px }` rule at equal
   specificity. Wider than the bay's 160px so the longer track gives
   finer control in the roomier drawer. */
.core-stems .hero-stem-panners {
  margin-top: 4px;
  width: min(280px, 100%);
}
/* The bay right-aligns the stem name against the track to hug it; in the
   drawer the section header + status are left-aligned, so left-align the
   panner labels too — their left edge then lines up with the text above. */
.core-stems .hero-stem-panner-label {
  text-align: left;
}

/* Inference-source hotswap — a compact 3-cell segmented control (full /
   instruments / vocals) sharing the drawer-tab recessed-active idiom but
   shrunk to fit under the status line. Active cell carves in with the
   teal stem accent so it ties to the stem-layers block it lives in;
   while a swap is in flight every cell is disabled and the strip dims. */
.source-mode-switch {
  display: flex;
  gap: 1px;
  margin-top: 2px;
  width: min(280px, 100%);
}
.source-mode-option {
  flex: 1 1 0;
  min-width: 0;
  padding: 4px 2px;
  background: linear-gradient(
    to bottom,
    rgba(255, 255, 255, 0.04) 0%,
    rgba(255, 255, 255, 0.01) 50%,
    rgba(0, 0, 0, 0.15) 100%
  );
  border: 1px solid transparent;
  border-radius: 3px;
  color: var(--text-dim);
  cursor: pointer;
  -webkit-appearance: none;
          appearance: none;
  font-family: var(--font-mono);
  font-size: 8px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  line-height: 1;
  transition:
    color 140ms ease-out,
    background 140ms ease-out,
    box-shadow 140ms ease-out,
    opacity 140ms ease-out;
}
.source-mode-option:hover:not(:disabled):not(.is-active) {
  color: var(--text);
  background: linear-gradient(
    to bottom,
    rgba(255, 255, 255, 0.06) 0%,
    rgba(255, 255, 255, 0.02) 50%,
    rgba(0, 0, 0, 0.1) 100%
  );
}
.source-mode-option.is-active {
  color: var(--dd-1);
  background: linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.35) 0%,
    rgba(0, 0, 0, 0.15) 50%,
    rgba(255, 255, 255, 0.04) 100%
  );
  border-color: color-mix(in srgb, var(--dd-1) 35%, transparent);
  box-shadow:
    inset 0 1px 2px rgba(0, 0, 0, 0.55),
    inset 0 0 0 1px rgba(0, 0, 0, 0.4);
  cursor: default;
}
/* Mid-swap: non-active cells are disabled — dim them so the strip reads
   as locked rather than merely unhovered. */
.source-mode-option:disabled:not(.is-active) {
  opacity: 0.4;
  cursor: default;
}
.source-mode-option:focus-visible {
  outline: 1px solid var(--dd-1);
  outline-offset: 1px;
}

/* ── Knob component ────────────────────────────────────────────────
   Rotary control. Body is 48px; with the label above + value below the
   group lands at ~80x76 — same footprint as a compact fader strip
   so racks can swap knobs <-> faders without layout churn. */
.knob-group {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  width: 64px;
  flex: 0 0 auto;
  user-select: none;
  -webkit-user-select: none;
}
.knob-label {
  font-family: var(--font-mono);
  font-size: 0.55em;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-dim);
  text-align: center;
  line-height: 1.2;
  min-height: 2.4em;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  cursor: help;
}
.knob-body {
  width: 48px;
  height: 48px;
  cursor: ns-resize;
  touch-action: none;
  /* Defensive — if any ancestor sets pointer-events: none for layout
     reasons, the knob body still receives pointerdown. */
  pointer-events: auto;
  position: relative;
  border-radius: 50%;
  /* Soft seat — the cap appears to rest on the panel with a small
     gap shadow beneath it. Compositor-friendly (no filter, no
     box-shadow on the body that would invalidate paint of the canvas
     siblings — drop-shadow is applied to the inner SVG only). */
  transition: transform 120ms ease-out, filter 120ms ease-out;
}
.knob-body:focus-visible {
  outline: none;
  filter: drop-shadow(0 0 4px var(--accent-glow));
}
/* Cap "grab" feedback — a very slight scale on hover/active so the
   user feels the click without anything bouncing. */
.knob-body:hover .knob-svg {
  transform: translateY(-0.5px);
}
.knob-svg {
  transition: transform 100ms ease-out;
}
.knob-svg {
  width: 100%;
  height: 100%;
  display: block;
}
/* Outer arc — the "scale" engraved around the cap. Background is a
   dim track; fill is the per-value tint that lights up as the user
   turns the knob. The track is slightly thicker than the fill so the
   fill reads as catching a highlight on top of an inset channel. */
.knob-arc-bg {
  stroke: rgba(0, 0, 0, 0.5);
  stroke-width: 3.5;
  stroke-linecap: round;
}
.knob-arc-fill {
  stroke-width: 2.5;
  stroke-linecap: round;
  /* Subtle blur halo around the fill arc — reads as the indicator
     light glowing through frosted plastic. Cheap because it's only
     painted on the small ring arc, not the whole canvas. */
  filter: drop-shadow(0 0 1.5px var(--knob-tint));
  transition: stroke 120ms ease-out, filter 120ms ease-out;
}
/* Hairline shadow under the cap. r=15.5 (slightly larger than the
   cap r=15) shifted down 1px so the bottom edge peeks out as a thin
   shadow lip. */
.knob-shadow {
  fill: rgba(0, 0, 0, 0.55);
  filter: blur(1px);
}
/* Indicator notch — etched recessed groove. The shadow layer is a
   dark line drawn 1px wider; the tint layer on top is the active
   pointing line. Together they read as a recessed channel with a
   highlight inside. */
.knob-indicator-shadow {
  stroke: rgba(0, 0, 0, 0.7);
  stroke-width: 3;
  stroke-linecap: round;
}
.knob-indicator {
  stroke-width: 1.5;
  stroke-linecap: round;
  transition: stroke 120ms ease-out;
}
.knob-value {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text);
  text-align: center;
  min-height: 14px;
  cursor: text;
}
.knob-value-input {
  font-family: var(--font-mono);
  font-size: 11px;
  width: 100%;
  background: var(--input-bg);
  border: 1px solid var(--accent-line);
  border-radius: 3px;
  color: var(--text);
  text-align: center;
  padding: 1px 2px;
}
.knob-kbd {
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: 0.04em;
  color: var(--text-dim);
  background: transparent;
  padding: 0;
  border: none;
}

/* SeedKnob — same cap chrome, button instead of div, dice glyph on
   the cap face. The cap cycles dice faces on click; the value cell
   shows the (truncated) uint32 seed beneath. */
.seed-knob-body {
  background: transparent;
  border: 0;
  padding: 0;
  cursor: pointer;
}
.seed-knob-body:focus-visible {
  outline: none;
  filter: drop-shadow(0 0 4px var(--accent-glow));
}
/* Dice face cycles on each click (state-driven, see SeedKnob.tsx).
   Brief opacity dip on press makes the click feel intentional without
   relying on transform interactions between SVG attr + CSS. */
.seed-knob-body:active .seed-dice-glyph {
  opacity: 0.7;
}
.seed-knob-value {
  /* Seed values can be ~7 chars wide ("12345678") — let the cell grow
     a touch wider than a regular knob's "0.50". */
  min-width: 56px;
}

/* ── Hero macros ────────────────────────────────────────────────────
   Permanent 3-knob row at the bottom-center of the canvas, riding
   above the closed drawer handle. Same <Knob/> primitive as CORE, just
   sized down (~52 px caps) so it feels like a focused subset of the
   drawer, not a duplicate. The cluster is content-sized + centered so
   it never spans the full viewport width — the show stays the focal
   point; the macros are the affordance.

   Visibility:
     - Hidden when the drawer is open (`body.drawer-open .hero-macros`
       at the bottom). The drawer's CORE tab covers the same params.
     - Hidden below 768 px (mobile gets <LiteControls/> instead).
     - JS guards via useSessionStore so it's also hidden pre-`ready`. */
.hero-macros {
  position: fixed;
  /* Bottom-row baseline shared with .audio-source-dock — same vertical
     centerline so the bottom row reads as one horizontal band. */
  bottom: max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-bottom, 0px));
  /* Stays centered on the viewport regardless of panel state — the
     bottom row (save pill, hero macros, now playing) is fixed visual
     furniture; the panel slides in over the canvas area above it. */
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: row;
  /* Knob cap-bottoms, divider bottom, and toggle bottom share one
     baseline — the knob labels sitting below the caps form a second
     row beneath. Previously `center` here floated the column-stacked
     toggle higher than the knobs. */
  align-items: flex-end;
  gap: 12px;
  padding: 10px 18px 12px;
  /* In-metaphor panel chrome: a recessed bay holding the three macros.
     Uses the same dark surface vocabulary as the audio-source-crate
     and turntable bay so the bottom row reads as one hardware unit.
     Border + inset hairline mirrors the cassette-deck inset look. */
  background: rgba(6, 6, 12, 0.62);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.04),
    0 4px 14px rgba(0, 0, 0, 0.4);
  -webkit-backdrop-filter: blur(8px);
          backdrop-filter: blur(8px);
  z-index: 5;
  /* Slight scale-down on knob size — the drawer's CORE row is the
     "do everything" surface; hero is the "perform" surface. */
  --knob-strip-w: 56px;
  pointer-events: auto;
}
.hero-macros .knob-group {
  width: var(--knob-strip-w);
  gap: 3px;
}
.hero-macros .knob-body {
  width: 44px;
  height: 44px;
}
.hero-macros .knob-svg {
  width: 44px;
  height: 44px;
}
.hero-macros .knob-label {
  font-size: 0.5em;
  min-height: 1.4em;
}
.hero-macros .knob-value {
  font-size: 10px;
}
.hero-macros .knob-kbd {
  font-size: 8px;
}
/* Inner row of knobs — horizontal. */
.hero-macros-knobs {
  display: flex;
  flex-direction: row;
  align-items: flex-end;
  gap: 22px;
  /* Anchor for StrengthOnboardingHint's absolute positioning so it
   * sits over the first knob without disturbing the flex row. */
  position: relative;
}

/* ─── StrengthOnboardingHint ─────────────────────────────────────────
 *  First-visit onboarding affordance — white "Turn this first"
 *  caption + hand-drawn arrow gif pointing right at the Strength
 *  knob (the leftmost in .hero-macros-knobs). Desktop only; mobile
 *  surfaces the same fader prominently on its own.
 *
 *  Layout: the hint sits to the LEFT of the first knob with the
 *  arrow's head pointing right toward the knob. ``right: 100%``
 *  anchors the right edge of the hint at the knob row's left edge;
 *  ``transform: translateX(-12px)`` nudges it left for breathing
 *  room so the arrow head lands just shy of the knob. */
.strength-onboarding-hint {
  position: absolute;
  /* z-index 6 — above knob chrome but well below the modal stack
   * (.almost-ready-* at 100+) so a stray hint never blocks a dialog. */
  z-index: 6;
  pointer-events: none;
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 4px;
  /* Anchor right edge at the first-knob's left edge with a small
   * outward nudge. Vertical: centered on the knob's cap so the
   * arrow's head (upper-right of the gif) reads as pointing at the
   * cap. */
  top: 50%;
  right: 100%;
  transform: translate(-12px, -50%);
  animation: strength-onboarding-hint-in 280ms ease-out;
}
@keyframes strength-onboarding-hint-in {
  from { opacity: 0; transform: translate(-6px, -50%); }
  to   { opacity: 1; transform: translate(-12px, -50%); }
}
@keyframes strength-onboarding-hint-bob {
  0%, 100% { transform: translate(0, 0); }
  50%      { transform: translate(-3px, 0); }
}
.strength-onboarding-hint-text {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.05em;
  font-style: italic;
  white-space: nowrap;
  /* White to match the gif's stroke color. Tiny shadow so the text
   * stays readable over any background the bay might sit over. */
  color: #fff;
  text-shadow: 0 0 6px rgba(0, 0, 0, 0.55);
  /* Bob only the text + arrow horizontally so the hint nudges back
   * toward the knob it's pointing at. */
  animation: strength-onboarding-hint-bob 2200ms ease-in-out infinite;
}
.strength-onboarding-hint-arrow {
  flex: 0 0 auto;
  width: 68px;
  height: 68px;
  /* Same gentle horizontal bob as the text — keeps the two reading
   * as one piece. */
  animation: strength-onboarding-hint-bob 2200ms ease-in-out infinite;
  /* Defensive: even on browsers that ignore alt="" + role="img"
   * stylings, this stays purely decorative. */
  user-select: none;
  pointer-events: none;
}
/* Hide on touch-only devices — mobile already surfaces the Strength
 * fader prominently and doesn't need a tutorial overlay. */
@media (hover: none) and (pointer: coarse) {
  .strength-onboarding-hint { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .strength-onboarding-hint,
  .strength-onboarding-hint-text,
  .strength-onboarding-hint-arrow {
    animation: none;
  }
}
/* Vertical divider between the knob row and the Full Controls toggle.
   Same inset-hairline vocabulary as VoiceTile's section divider; reads
   as "performance palette | navigation" two-zone layout. */
.hero-macros-divider {
  width: 1px;
  align-self: stretch;
  margin: 0 4px;
  background: var(--frame-line);
  flex: 0 0 auto;
}
/* Full Controls toggle — small caret row centered beneath the knobs.
   Sits inside the bay so the bottom row reads as ONE hardware unit
   (display + knobs + button), not two stacked elements. */
/* Inline LoRA strength faders inside the hero bay. Compact vertical
   pair sitting between the knob row and the tool column — same drag
   semantics as the (removed) left-edge StylePanel, just shrunk to fit
   the bay's vertical footprint. */
.hero-macros-styles,
.hero-macros-stems {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}
/* Center the shorter sections (stems + tools) within the panel's
   vertical band rather than letting them fall to the bottom edge. The
   knob row + LoRA fader pair are the tallest things in the bay; with
   flex-end alignment everything else sticks to the bottom and the
   tops drift, which reads as unbalanced. Letting these two sections
   center re-balances the whole bay around the body band. */
.hero-macros-stems,
.hero-macros-tools {
  align-self: center;
}
.hero-macros-group-row {
  display: flex;
  flex-direction: row;
  align-items: flex-end;
  gap: 10px;
}
.hero-macros-group-label {
  font-family: var(--font-mono);
  /* Matches `.hero-macros .knob-label` (also 0.5em) so STEM LAYERS
     sits at the same typographic weight as DENOISE / STRUCTURE /
     TIMBRE / SEED above the other column labels. */
  font-size: 0.5em;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-dim);
  white-space: nowrap;
}
/* Italicised one-liner that sits between the section header and the
   panners — mirrors the status copy the floating StemOverlayPanel used
   to render ("Ripping stems…" / "Stem rip failed" / "Stems will load
   on play" / "Inference source: <mode>"). Tightened to the header
   above with a negative margin so the status reads as a subtitle, not
   its own row. */
.hero-macros-group-status {
  font-family: var(--font-mono);
  font-size: 0.42em;
  font-style: italic;
  letter-spacing: 0.04em;
  color: var(--text-dim);
  white-space: nowrap;
  text-align: center;
  margin-top: -4px;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
}
.hero-style-fader {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  /* 48px wide so 8-char labels (the labelFor slice ceiling) like
     "DUBSTEP" / "BAROQUE" fit without ellipsis at the 7.5px font
     size + tightened letter-spacing below. */
  width: 48px;
}
.hero-style-fader-label {
  font-family: var(--font-mono);
  font-size: 7.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
.hero-style-fader-track {
  position: relative;
  /* Generous 20px hit target — matches the cap width — so the slider
     is comfortable to grab. The visual track (::before) and fill are
     absolutely positioned at 50%, so the rendered weight stays 3px
     regardless of the hit-area width. */
  width: 20px;
  margin: 0 auto;
  height: 56px;
  cursor: ns-resize;
  touch-action: none;
}
.hero-style-fader-track::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 0; bottom: 0;
  width: 3px;
  transform: translateX(-50%);
  background: linear-gradient(
    to right,
    rgba(0, 0, 0, 0.6) 0%,
    rgba(0, 0, 0, 0.45) 50%,
    rgba(255, 255, 255, 0.04) 100%
  );
  border-radius: 2px;
  box-shadow:
    inset 1px 0 0 rgba(0, 0, 0, 0.7),
    inset -1px 0 1px rgba(255, 255, 255, 0.05);
}
.hero-style-fader-fill {
  position: absolute;
  bottom: 0;
  left: 50%;
  width: 3px;
  transform: translateX(-50%);
  background: linear-gradient(
    to top,
    color-mix(in srgb, var(--accent) 70%, #000) 0%,
    var(--accent) 100%
  );
  border-radius: 2px;
  pointer-events: none;
  transition: height 70ms linear;
}
.hero-style-fader-cap {
  position: absolute;
  left: 50%;
  width: 20px;
  height: 8px;
  background: linear-gradient(
    to bottom,
    rgb(78, 84, 96) 0%,
    rgb(42, 46, 54) 48%,
    rgb(18, 20, 24) 100%
  );
  border-radius: 2px;
  transform: translate(-50%, 50%);
  pointer-events: none;
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.65),
    0 0 0 1px rgba(0, 0, 0, 0.55),
    inset 0 1px 0 rgba(255, 255, 255, 0.15),
    inset 0 -1px 0 rgba(0, 0, 0, 0.5);
  transition: bottom 70ms linear;
}
.hero-style-fader-cap::before {
  content: "";
  position: absolute;
  left: 2px;
  right: 2px;
  top: 50%;
  height: 1px;
  background: var(--accent);
  transform: translateY(-50%);
  border-radius: 1px;
  box-shadow: 0 0 3px var(--accent);
}
.hero-style-fader-value {
  font-family: var(--font-mono);
  font-size: 9px;
  color: var(--text);
  line-height: 1;
}
/* Keyboard-shortcut hint under the value, matching the .knob-kbd
   vocabulary used by the bay's knob row. Honored by the same
   `.mixer-rack--no-kbd-hints kbd` toggle as the in-drawer hints
   (live globally — when the operator opts out of kbd hints, the
   bay's faders hide theirs too). */
.hero-style-fader-kbd {
  font-family: var(--font-mono);
  font-size: 7.5px;
  letter-spacing: 0.04em;
  color: var(--text-dim);
  line-height: 1;
  background: transparent;
  border: none;
  padding: 0;
}
.mixer-rack--no-kbd-hints .hero-style-fader-kbd { display: none; }
.hero-style-fader--empty { opacity: 0.35; }
.hero-style-fader--empty .hero-style-fader-track { cursor: default; }

/* ── Stem layers: horizontal panners stacked vertically ──────────────
   Visually distinct from the LoRA style faders next to them — the
   horizontal layout reads as "amount of stem to mix in" rather than
   "drive this style up/down". Each row is its own track; the column
   stacks them so the section reads top-to-bottom. The teal tint is
   the t=1.0 endpoint of the knob arc gradient — different enough from
   the orange accent that the section reads as its own zone. */
.hero-stem-panners {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 160px;
}
.hero-stem-panner {
  /* Teal tint shared by the fill bar and cap highlight. Localised
     as a custom property so future LoRA-style faders or other
     "blend" controls could opt into the same look by overriding
     `--stem-tint`. */
  --stem-tint: #3db6be;
  display: grid;
  /* label | track | value-stack */
  grid-template-columns: 32px 1fr 34px;
  align-items: center;
  column-gap: 8px;
}
.hero-stem-panner-label {
  /* Button reset — the label is now a click-to-mute toggle that
     mirrors the original StemOverlayPanel toggle behavior. The
     subtle indicators that it's interactive: cursor: pointer +
     color brightens to the teal tint when active (aria-pressed),
     and a hover state that previews the active color when muted. */
  appearance: none;
  -webkit-appearance: none;
  border: none;
  padding: 0;
  background: transparent;
  font: inherit;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 7.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-dim);
  text-align: right;
  line-height: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: color 140ms ease-out;
}
.hero-stem-panner-label[aria-pressed="true"] {
  color: var(--stem-tint);
}
.hero-stem-panner-label:hover:not(:disabled) {
  color: color-mix(in srgb, var(--stem-tint) 60%, var(--text));
}
.hero-stem-panner-label:disabled {
  cursor: default;
}
.hero-stem-panner-label:focus-visible {
  outline: 1px solid var(--stem-tint);
  outline-offset: 2px;
  border-radius: 2px;
}
.hero-stem-panner-track {
  position: relative;
  /* 22px hit target — comfortable to grab; the rendered track stays
     3px tall via the centered ::before, matching the LoRA fader's
     visual weight. */
  height: 22px;
  cursor: ew-resize;
  touch-action: none;
}
.hero-stem-panner-track::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 3px;
  transform: translateY(-50%);
  background: linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.6) 0%,
    rgba(0, 0, 0, 0.45) 50%,
    rgba(255, 255, 255, 0.04) 100%
  );
  border-radius: 2px;
  box-shadow:
    inset 0 1px 0 rgba(0, 0, 0, 0.7),
    inset 0 -1px 1px rgba(255, 255, 255, 0.05);
}
.hero-stem-panner-fill {
  position: absolute;
  top: 50%;
  left: 0;
  height: 3px;
  transform: translateY(-50%);
  background: linear-gradient(
    to right,
    color-mix(in srgb, var(--stem-tint) 70%, #000) 0%,
    var(--stem-tint) 100%
  );
  border-radius: 2px;
  pointer-events: none;
  transition: width 70ms linear;
}
.hero-stem-panner-cap {
  position: absolute;
  top: 50%;
  width: 8px;
  height: 18px;
  background: linear-gradient(
    to bottom,
    rgb(78, 84, 96) 0%,
    rgb(42, 46, 54) 48%,
    rgb(18, 20, 24) 100%
  );
  border-radius: 2px;
  transform: translate(-50%, -50%);
  pointer-events: none;
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.65),
    0 0 0 1px rgba(0, 0, 0, 0.55),
    inset 1px 0 0 rgba(255, 255, 255, 0.15),
    inset -1px 0 0 rgba(0, 0, 0, 0.5);
  transition: left 70ms linear;
}
.hero-stem-panner-cap::before {
  content: "";
  position: absolute;
  top: 2px;
  bottom: 2px;
  left: 50%;
  width: 1px;
  background: var(--stem-tint);
  transform: translateX(-50%);
  border-radius: 1px;
  box-shadow: 0 0 3px var(--stem-tint);
}
.hero-stem-panner-value {
  font-family: var(--font-mono);
  font-size: 9px;
  color: var(--text);
  text-align: right;
  line-height: 1;
}
.hero-stem-panner--empty { opacity: 0.35; }
.hero-stem-panner--empty .hero-stem-panner-track { cursor: default; }

/* Record pill — simplified two-state version of the turntable. Idle:
   "Record" with a dim red dot. Active: "MM:SS" with the dot pulsing
   to make recording state unmistakable. Click toggles the same
   `dd:toggle-record` event the floating button used. */
.hero-macros-rec-dot {
  display: inline-block;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: #e84f3d;
  flex-shrink: 0;
  box-shadow: 0 0 4px rgba(232, 79, 61, 0.45);
}
.hero-macros-rec--active .hero-macros-rec-dot {
  animation: hero-macros-rec-pulse 1s ease-in-out infinite;
}
.hero-macros-rec--active {
  color: var(--text-strong);
  border-color: rgba(232, 79, 61, 0.65);
}
@keyframes hero-macros-rec-pulse {
  0%, 100% {
    opacity: 0.6;
    box-shadow: 0 0 4px rgba(232, 79, 61, 0.4);
    transform: scale(1);
  }
  50% {
    opacity: 1;
    box-shadow: 0 0 10px rgba(232, 79, 61, 0.85);
    transform: scale(1.18);
  }
}
@media (prefers-reduced-motion: reduce) {
  .hero-macros-rec--active .hero-macros-rec-dot { animation: none; opacity: 1; }
}

/* Stacked tool column on the right side of the bay: Record → Curve
   Editor → Full Controls. All three cells share the same hairline-cell
   vocabulary; Simple/Full Controls keeps the caret affordance to read
   as the primary action. */
.hero-macros-tools {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 4px;
  min-width: 116px;
}
.hero-macros-tool {
  display: inline-flex;
  flex-direction: row;
  align-items: center;
  justify-content: flex-start;
  gap: 6px;
  padding: 5px 10px 6px;
  background: transparent;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  color: var(--text-dim);
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  line-height: 1;
  -webkit-appearance: none;
          appearance: none;
  transition: color 140ms ease-out, border-color 140ms ease-out, background 140ms ease-out;
}
.hero-macros-tool:hover {
  color: var(--text-strong);
  border-color: var(--accent-line);
  background: rgba(255, 255, 255, 0.02);
}
.hero-macros-tool--active {
  color: var(--text-strong);
  border-color: var(--accent-line);
}
.hero-macros-tool-label {
  line-height: 1;
}
.hero-macros-toggle {
  display: inline-flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
  padding: 5px 10px 6px;
  background: transparent;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  color: var(--text-dim);
  cursor: pointer;
  -webkit-appearance: none;
          appearance: none;
  transition: color 140ms ease-out, border-color 140ms ease-out, background 140ms ease-out;
}
.hero-macros-toggle:hover {
  color: var(--text-strong);
  border-color: var(--accent-line);
  background: rgba(255, 255, 255, 0.02);
}
.hero-macros-toggle-label {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  line-height: 1;
}
.hero-macros-toggle-caret {
  font-size: 10px;
  line-height: 1;
  transition: transform 140ms ease-out;
}
.hero-macros--drawer-open .hero-macros-toggle {
  /* When the drawer is open the toggle is the only thing visible
     in the bay — give it a bit more presence so the user spots the
     close affordance. */
  color: var(--text-strong);
  border-color: var(--accent-line);
}

/* ─── MIDI in jack toggle ─────────────────────────────────────────────
   Sits under Full Controls in the right-side tools cluster. NOT a
   button-styled cell like Record / Curve Editor / Full Controls; just
   a small red LED with a mono-caps "MIDI in" label next to it. Click
   flips the store's ``enabled`` flag, which gates the browser's
   ``requestMIDIAccess`` permission prompt — so a fresh visitor never
   sees the prompt unless they explicitly turn MIDI on here.
   Skeuomorphic LED: layered radial gradient (lit core + ambient halo)
   over a dark inset bezel. Off state strips the gradients and shows
   a dim recess so the jack reads as "unpowered". */
.midi-in-toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  /* Center inside .hero-macros-tools' column so the jack reads as
   * a standalone affordance rather than a left-aligned sibling of
   * the Full Controls button. */
  align-self: center;
  /* Transparent — no card chrome. Lives flush against the panel like
   * a labelled jack on a hardware rack, not a button to click. */
  background: transparent;
  border: 0;
  padding: 6px 2px;
  margin: 0;
  cursor: pointer;
  font: inherit;
  color: var(--text-dim);
  -webkit-appearance: none;
          appearance: none;
  transition: color 140ms ease-out;
}
.midi-in-toggle:hover { color: var(--text); }
.midi-in-toggle:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
  border-radius: 4px;
}
.midi-in-toggle-led {
  /* The LED itself — a round glass dome sunk into the panel. Even
   * when OFF, we want the dome to read as a physical object hit by
   * ambient room light from above, not a flat dot that disappears
   * into the panel. Layered backgrounds achieve that:
   *   1. A top-anchored specular highlight (linear, lighter at top
   *      → darker at bottom) — sells the curved glass surface.
   *   2. The dark recess (radial) — the unpowered lens body.
   * The outer ring (1 px lighter outline) carves the dome out of
   * the panel so its silhouette is always readable. */
  position: relative;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background:
    linear-gradient(
      180deg,
      rgba(255, 255, 255, 0.22) 0%,
      rgba(255, 255, 255, 0.06) 35%,
      rgba(0, 0, 0, 0) 60%
    ),
    radial-gradient(
      circle at 50% 55%,
      rgba(40, 30, 30, 0.85) 0%,
      rgba(12, 10, 10, 0.98) 100%
    );
  box-shadow:
    inset 0 1px 1px rgba(0, 0, 0, 0.6),
    inset 0 -1px 0 rgba(255, 255, 255, 0.05),
    /* Subtle outer ring acts as a polished bezel: catches "light"
     * so the dome stays visible against a dark panel even unpowered. */
    0 0 0 1px rgba(255, 255, 255, 0.1),
    0 1px 1px rgba(0, 0, 0, 0.5);
}
.midi-in-toggle-led::after {
  content: "";
  position: absolute;
  inset: 1px;
  border-radius: 50%;
  background: radial-gradient(
    circle at 40% 40%,
    rgba(255, 240, 230, 0.95) 0%,
    rgba(255, 80, 50, 1) 40%,
    rgba(140, 18, 10, 1) 100%
  );
  box-shadow:
    0 0 4px rgba(255, 80, 50, 0.85),
    0 0 10px rgba(255, 60, 40, 0.55),
    inset 0 1px 0 rgba(255, 255, 255, 0.35);
  opacity: 0;
  transition: opacity 140ms ease-out;
}
.midi-in-toggle--on .midi-in-toggle-led::after,
.midi-in-toggle--warn .midi-in-toggle-led::after {
  opacity: 1;
}
.midi-in-toggle--warn .midi-in-toggle-led::after {
  /* Permission denied / no Web MIDI — amber LED so the user can tell
   * the toggle is on but the platform isn't delivering. Distinct from
   * the "off" dark state and the "on" healthy red. */
  background: radial-gradient(
    circle at 40% 40%,
    rgba(255, 240, 200, 0.95) 0%,
    rgba(230, 180, 60, 1) 40%,
    rgba(120, 80, 10, 1) 100%
  );
  box-shadow:
    0 0 4px rgba(230, 180, 60, 0.85),
    0 0 10px rgba(220, 160, 40, 0.55),
    inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.midi-in-toggle-label {
  font-family: var(--font-mono);
  font-size: 0.62em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  line-height: 1;
}
/* Hide on touch-only devices — phones have nothing to gain from MIDI
 * (no controllers, no hardware) and the toggle would just clutter the
 * mobile chrome. The useMidi hook also guards against
 * ``navigator.requestMIDIAccess`` being absent, but hiding the
 * affordance keeps the surface honest. */
@media (hover: none) and (pointer: coarse) {
  .midi-in-toggle { display: none; }
}
/* When the drawer is open the knob row hides (params live in the
   drawer's CORE tab now) but the toggle button stays — it's the
   only way to close the drawer with the bottom handle gone. */
body.drawer-open .hero-macros-knobs,
body.drawer-open .hero-macros-styles,
body.drawer-open .hero-macros-stems,
body.drawer-open .hero-macros-divider,
.hero-macros--curve-open .hero-macros-knobs,
.hero-macros--curve-open .hero-macros-styles,
.hero-macros--curve-open .hero-macros-stems,
.hero-macros--curve-open .hero-macros-divider {
  display: none;
}

/* When the panel is open, the bay collapses to its tool buttons.
   Stack them horizontally so the bay reads as one short row sitting
   below the (shrunken) graph instead of a tall column poking up into
   the graph labels. Same treatment when the curve editor is open —
   knobs/faders are hidden in that mode too. */
body.drawer-open .hero-macros-tools,
body.curve-open .hero-macros-tools {
  flex-direction: row;
  min-width: 0;
  gap: 6px;
}

/* Curve editor takes focus. Hide the moving-lines graph + its lane
   labels entirely when the curve editor is open — the curve canvas
   IS the user's attention. The graph itself sits behind the curve
   overlay (z=2 vs z=51) but its labels and lines were bleeding
   visibly across the right edge of the curve canvas. */
body.curve-open #install-video-area #graph-wrap .graph-lane-labels,
body.curve-open #install-video-area #graph {
  opacity: 0;
  pointer-events: none;
}
.hero-macros--drawer-open,
.hero-macros--curve-open {
  /* Compact bay — just the toggle row remains. From the curve editor,
     the toggle is also the user's way back into Full Controls. */
  padding: 4px 14px;
  gap: 0;
}
/* Mobile: LiteControls already does this job inside the drawer.
   Hero macros would crowd the bottom-corner controls on touch. */
@media (max-width: 768px) {
  .hero-macros {
    display: none;
  }
}

/* ── Footer links ───────────────────────────────────────────────────
   Always-visible pill at the very bottom of the viewport with VST
   waitlist + feedback links. Quiet visual weight (low opacity, mono
   caps), but the items are always one click away from any canvas
   state. Modeled on the inShaper "VST3 1.0.0 HELP" footer
   convention. */
.footer-links {
  position: fixed;
  /* Vertically centered on the HaloBadge midline. Badge: 44px tall at
     `top: hud/2 - 10 + top-bias`, hamburger middle-line is the SVG's
     viewBox y=12 / 24 → 22px from box top, so middle-line lands at
     `hud/2 - 10 + top-bias + 22 = hud/2 + 12 + top-bias`. With
     top-bias 5, that's `hud/2 + 17`. Pill content-center sits at
     `pill-top + 12` (1px border + 5px top padding + 6px to centre of
     the 12px icon/cap row inside .footer-link). Match by setting
     pill-top = hud/2 + 5, which factoring out top-bias means the
     literal constant is 0. (Previous `+ 4px` here put the row 4px low.) */
  top: max(calc(var(--hud-thickness) / 2 + var(--top-bias, 5px)),
           env(safe-area-inset-top, 0px));
  right: 140px;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  z-index: 4;
  pointer-events: auto;
}
.footer-link {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 5px 12px 6px;
  background: rgba(255, 255, 255, 0.02);
  border: 1px solid var(--frame-line);
  border-radius: 999px;
  color: var(--text-dim);
  text-decoration: none;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  line-height: 1;
  -webkit-appearance: none;
          appearance: none;
  transition:
    color 140ms ease-out,
    background 140ms ease-out,
    border-color 140ms ease-out;
}
.footer-link:hover {
  color: var(--text-strong);
  background: rgba(255, 255, 255, 0.06);
  border-color: var(--accent-line);
}
/* Primary CTA still uses the accent border + accent text — but no
   filled background. Reads as "the more important one" without
   shouting. */
.footer-link--cta {
  border-color: var(--accent-line);
  color: var(--accent);
}
.footer-link--cta:hover {
  color: var(--accent-hover);
  border-color: var(--accent-hover);
  background: rgba(255, 255, 255, 0.04);
}
.footer-link-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.footer-link-label {
  line-height: 1;
}
/* Mobile: hide — the lite drawer + halo menu cover this on mobile,
   and the bottom-edge real estate is already tight. */
@media (max-width: 768px) {
  .footer-links {
    display: none;
  }
}

/* ── Voice tab ──────────────────────────────────────────────────────
   The model's internal voice channels — V1–V8 (always-available) +
   M1–M6 (hand-tuned morph). Two labeled sub-rows so the user reads
   the structure: "internal voices vs tuned morph." */
.mixer-tile--voice {
  gap: 8px;
}
/* Experimental-feature notice that sits under the "Channels" title.
   Hairline accent-color frame + an uppercase mono title to signal "read
   this before sweeping the faders". Recessed background so it reads as
   advisory rather than a primary control surface. */
.voice-tile-warning {
  position: relative;
  margin: 0 0 6px;
  padding: 6px 22px 7px 8px;
  border: 1px solid var(--accent-line);
  border-radius: var(--radius-sm);
  background: rgba(255, 184, 60, 0.04);
}
/* Dismiss the experimental-feature notice — the dismissal is persisted
   to localStorage by VoiceTile. */
.voice-tile-warning-close {
  position: absolute;
  top: 3px;
  right: 4px;
  width: 16px;
  height: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: transparent;
  border: none;
  border-radius: 3px;
  color: var(--text-dim);
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
  transition: color 100ms ease, background 100ms ease;
}
.voice-tile-warning-close:hover {
  color: var(--text-strong);
  background: rgba(255, 255, 255, 0.06);
}
.voice-tile-warning-title {
  font-family: var(--font-mono);
  font-size: 0.6em;
  letter-spacing: var(--tracking-bar);
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 4px;
}
.voice-tile-warning-body {
  font-size: 11px;
  line-height: 1.45;
  color: var(--text-dim);
  margin: 0;
  max-width: 70ch;
}
/* Two-column grid shared by every row inside the voice tile (channels
   on row 1, steering on row 2 when present). Grid (not flex) so the
   columns auto-size to the widest content across rows — that's what
   makes "manual steering" line up with "channel groups" above. */
.voice-sections-row {
  display: grid;
  grid-template-columns: auto 1px auto;
  row-gap: 14px;
  column-gap: 14px;
  align-items: stretch;
}
.voice-section {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
}
.voice-section-divider {
  width: 1px;
  align-self: stretch;
  background: var(--frame-line);
  margin: 4px 0;
}
.voice-section-label {
  font-family: var(--font-mono);
  font-size: 0.55em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-dim);
}


/* A tile is a labeled card that groups related controls. Tiles are sized
   to their content (no fixed widths beyond what their inner channels and
   per-tile rules dictate), which lets them wrap densely. */
.mixer-tile {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 8px 10px 10px;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  background: rgba(255, 255, 255, 0.012);
  flex: 0 0 auto;
}
.mixer-tile-label {
  font-family: var(--font-mono);
  font-size: 0.6em;
  letter-spacing: var(--tracking-bar);
  color: var(--text-dim);
  text-transform: uppercase;
}
/* Channels row inside a tile: one horizontal row of fader strips. */
.mixer-channels {
  display: flex;
  flex-wrap: nowrap;
  gap: 4px;
  align-items: stretch;
  flex: 1 1 auto;
  min-height: 0;
}

/* ============================================================================
   Channel strips — vertical fader columns inside a tile. The tile provides
   the card frame; each strip is transparent. There is no Simple/Pro split
   and no horizontal scrolling — every control is reachable on screen via
   tile wrapping.
   ============================================================================ */
.slider-group {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  flex: 0 0 auto;
  width: var(--slider-strip-w, 50px);
  background: transparent;
  border: none;
  padding: 0;
}

.slider-label {
  font-family: var(--font-mono);
  font-size: 0.55em;
  color: var(--text-dim);
  letter-spacing: 0.04em;
  line-height: 1.2;
  text-transform: uppercase;
  text-align: center;
  width: 100%;
  /* Reserve 2 lines so 1-word + 2-word labels align track tops. */
  min-height: 2.4em;
  display: flex;
  align-items: flex-end;
  justify-content: center;
}

/* Real-fader skeumorphic visual. The track column is wide enough for
   the cap to sit centered with breathing room either side. The
   "groove" (inset slot the cap rides in) is narrow + recessed, the
   colored fill rises inside the slot, and the thumb is a wide
   horizontal cap with a center indicator line — same anatomy as a
   hardware mixer fader. */
.slider-track {
  position: relative;
  width: 32px;
  flex: 1 1 auto;
  min-height: 78px;
  background: transparent;
  cursor: ns-resize;
  touch-action: none;
}
/* The groove — a vertical channel inset into the panel. Inner shadow
   sells the recess; the channel itself is dark with a hairline
   highlight on the inside-top edge from the gradient. */
.slider-track::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 0; bottom: 0;
  width: 4px;
  transform: translateX(-50%);
  background: linear-gradient(
    to right,
    rgba(0, 0, 0, 0.6) 0%,
    rgba(0, 0, 0, 0.45) 50%,
    rgba(255, 255, 255, 0.04) 100%
  );
  border-radius: 2px;
  box-shadow:
    inset 1px 0 0 rgba(0, 0, 0, 0.7),
    inset -1px 0 1px rgba(255, 255, 255, 0.05),
    0 1px 0 rgba(255, 255, 255, 0.04);
  transition: box-shadow 70ms linear;
}
/* Fill rises inside the groove. Still tinted from --slider-tint so
   the per-value color shows through the slot. */
.slider-fill {
  position: absolute;
  bottom: 0;
  left: 50%;
  width: 4px;
  transform: translateX(-50%);
  background: linear-gradient(
    to top,
    color-mix(in srgb, var(--slider-tint, var(--accent)) 70%, #000) 0%,
    var(--slider-tint, var(--accent)) 100%
  );
  border-radius: 2px;
  pointer-events: none;
  filter: drop-shadow(0 0 2px color-mix(in srgb, var(--slider-tint, var(--accent)) 40%, transparent));
  transition: height 70ms linear, background 70ms linear;
}
/* The cap — wide horizontal handle straddling the groove. Top half is
   lit (radial highlight), bottom half is shadowed. A 1px center
   indicator line catches the per-value tint so the cap reads as
   "pointing at" its current position on the groove. */
.slider-thumb {
  position: absolute;
  left: 50%;
  width: 22px;
  height: 10px;
  background:
    linear-gradient(
      to bottom,
      rgb(78, 84, 96) 0%,
      rgb(42, 46, 54) 48%,
      rgb(18, 20, 24) 100%
    );
  border-radius: 2px;
  transform: translate(-50%, 50%);
  pointer-events: none;
  /* Outer drop shadow seats the cap on the panel; inner highlight on
     the top edge sells the bevel. */
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.65),
    0 0 0 1px rgba(0, 0, 0, 0.55),
    inset 0 1px 0 rgba(255, 255, 255, 0.15),
    inset 0 -1px 0 rgba(0, 0, 0, 0.5);
  transition: bottom 70ms linear, box-shadow 70ms linear;
}
/* Center indicator line on the cap — colored by the per-value tint
   so the cap actively shows what value it's pointing at. */
.slider-thumb::before {
  content: "";
  position: absolute;
  left: 2px;
  right: 2px;
  top: 50%;
  height: 1px;
  background: var(--slider-tint, var(--accent));
  transform: translateY(-50%);
  border-radius: 1px;
  box-shadow: 0 0 3px var(--slider-tint, var(--accent));
  transition: background 70ms linear;
}
.slider-group.dragging .slider-fill,
.slider-group.dragging .slider-thumb,
.slider-group.dragging .slider-track::before,
.slider-group.dragging .slider-value { transition: none; }
.slider-group.active .slider-track::before {
  box-shadow: 0 0 12px var(--accent-glow);
}
.slider-value {
  font-family: var(--font-mono);
  font-size: 0.65em;
  color: var(--slider-tint, var(--accent));
  transition: color 70ms linear;
}

.slider-group kbd,
.blend-kbd,
.send-kbd {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 2px 5px;
  min-height: 18px;
  background: var(--frame);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: 0.55em;
  color: var(--text-dim);
  white-space: nowrap;
}
/* Send button keeps its own layout; the kbd sits to the right of the
   button's label with a small gap. */
.send-prompt-btn .send-kbd { margin-left: 8px; font-size: 0.65em; }
/* Blend control already lays out left-to-right; spacing handled by gap. */
#blend-control .blend-kbd { margin-left: 6px; }

/* DCW (wavelet correction) toggle + mode/wavelet selectors. Lives inside
   the DCW tile alongside the two DCW faders. */
.dcw-panel {
  display: flex;
  flex-direction: column;
  align-self: stretch;
  align-items: stretch;
  gap: 6px;
  flex: 0 0 auto;
  width: 130px;
  padding: 0;
  border: none;
  background: transparent;
}

/* Seed tile content — dice button stacked over the value display. */
.seed-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding-top: 4px;
  flex: 1 1 auto;
  justify-content: center;
}

/* Prompts tile spans the full drawer width on its own row beneath the
   fader tiles. flex-basis:100% combined with the parent's flex-wrap means
   it always pushes to a new line; min-height keeps the row tall enough
   that the textareas inside have somewhere to grow into (the wrap
   container uses align-content:flex-start, so rows are content-sized —
   without a minimum the second row would collapse). */
.mixer-tile-prompts {
  flex: 1 1 100%;
  width: auto;
  max-width: 100%;
  min-height: 110px;
}
.mixer-tile-prompts #prompt-section { flex: 1 1 auto; }
.dcw-toggle {
  background: transparent;
  color: var(--text-dim);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  padding: 4px 8px;
  font-family: inherit;
  font-size: 0.7em;
  letter-spacing: 0.04em;
  cursor: pointer;
  transition: color 120ms, border-color 120ms, background 120ms;
}
.dcw-toggle:hover {
  color: var(--accent);
  border-color: var(--accent);
  background: var(--accent-soft);
}
.dcw-toggle.active {
  background: var(--accent-soft);
  border-color: var(--accent);
  color: var(--text);
}
.dcw-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
  font-size: 0.65em;
  color: var(--text-dim);
}
.dcw-row-label {
  white-space: nowrap;
}
.dcw-select {
  background: var(--input-bg);
  color: var(--text);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  padding: 2px 4px;
  font-family: inherit;
  font-size: 0.95em;
  cursor: pointer;
}
.dcw-select:focus {
  outline: none;
  border-color: var(--accent);
}

/* ============================================================================
   Inputs — ws-url + prompt textareas share one focus language.
   ============================================================================ */
.ws-url-input,
.prompt-input {
  background: var(--input-bg);
  border: 1px solid var(--frame-line);
  color: var(--text-dim);
  font-family: var(--font-mono);
  border-radius: var(--radius-sm);
  transition: border-color 120ms, color 120ms;
}
.ws-url-input:focus,
.prompt-input:focus {
  outline: none;
  border-color: var(--accent);
  color: var(--text);
}

.ws-url-input {
  padding: 6px 10px;
  min-height: 32px;
  font-size: 0.7em;
  width: 100%;
}

.fixture-select {
  background: var(--input-bg);
  border: 1px solid var(--frame-line);
  color: var(--text-dim);
  font-family: var(--font-mono);
  border-radius: var(--radius-sm);
  padding: 6px 10px;
  min-height: 44px;
  font-size: 0.7em;
  max-width: 240px;
  transition: border-color 120ms, color 120ms;
}
.fixture-select:focus {
  outline: none;
  border-color: var(--accent);
  color: var(--text);
}

.prompt-input {
  padding: 6px 8px;
  font-family: inherit;
  font-size: 0.75em;
  line-height: 1.4;
  width: 100%;
  min-height: 56px;
  resize: vertical;
}

/* ============================================================================
   Prompt section — A/B prompts with a blend slider and Send button. Lives
   inside the Prompts tile.
   ============================================================================ */
/* Horizontal layout: [Prompt A] [A blend B] [Prompt B] [Send]. The two
   textareas split the leftover width evenly; the blend control and Send
   button stay sized to their content so the textareas keep most of the
   real estate. No scroll bar — everything fits in the prompts row. */
#prompt-section {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 10px;
  /* The Full Controls panel is ~360-440px wide; the prior horizontal
     [A | blend | B | Send] split made the textareas ~80px wide each,
     so multi-word prompts wrapped one character per line. Stack
     vertically so each textarea gets the full panel width. */
}

.prompt-slot {
  display: flex;
  flex-direction: column;
  gap: 4px;
  width: 100%;
  min-width: 0;
}

.prompt-slot .prompt-input {
  width: 100%;
  min-height: 56px;
  resize: vertical;
}

.prompt-label,
.blend-label,
.blend-value {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-dim);
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

#blend-control {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 0;
  width: 100%;
}

.blend-label { font-weight: 600; flex-shrink: 0; }
.blend-value {
  color: var(--accent);
  flex-shrink: 0;
  min-width: 2.8em;
  text-align: center;
}

/* Blend slider — native range input retheme (Webkit + Firefox). */
#prompt-blend {
  flex: 1;
  -webkit-appearance: none;
  appearance: none;
  height: 6px;
  background: linear-gradient(to right, var(--accent-line), var(--accent));
  border-radius: 3px;
  outline: none;
  cursor: pointer;
  accent-color: var(--accent);
}
#prompt-blend::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 18px;
  height: 18px;
  background: var(--text-strong);
  border: 2px solid var(--accent);
  border-radius: 50%;
  cursor: pointer;
  box-shadow: 0 0 6px var(--accent-glow);
}
#prompt-blend::-moz-range-thumb {
  width: 18px;
  height: 18px;
  background: var(--text-strong);
  border: 2px solid var(--accent);
  border-radius: 50%;
  cursor: pointer;
  box-shadow: 0 0 6px var(--accent-glow);
}

/* ============================================================================
   MIDI badge + diag — operator-only, lives in the operator strip at the
   top of the Advanced drawer. The badge sits inline with the other
   operator controls; the diag panel drops to a full-width row beneath
   them when toggled (it stays hidden by default).
   ============================================================================ */
#install-midi-slot {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  align-items: center;
  flex: 1 1 auto;
  min-width: 0;
}

#midi-status {
  display: inline-block;
  padding: 4px 10px;
  background: transparent;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: 0.65em;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-dim);
  cursor: pointer;
  flex-shrink: 0;
  transition: border-color 200ms, color 200ms, background 120ms;
}
#midi-status:hover         { border-color: var(--accent);   color: var(--text); background: var(--accent-soft); }
#midi-status.midi-ok       { border-color: var(--accent);   color: var(--accent); }
#midi-status.midi-warn     { border-color: var(--warn);     color: var(--warn); }
#midi-status.midi-error    { border-color: var(--critical); color: var(--critical); }

#midi-diag {
  flex: 1 1 100%;
  max-height: 110px;
  overflow-y: auto;
  padding: 8px;
  background: var(--input-bg);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: 0.62em;
  color: var(--text);
  white-space: pre-wrap;
  word-break: break-all;
  user-select: text;
  -webkit-user-select: text;
}

/* ── Status placard ─────────────────────────────────────────────────────
   Bottom-center frosted-glass surface that surfaces session messages
   (loading, connecting, mid-session swaps, errors). Visual vocabulary is
   intentionally lifted from `.halo-menu` (blur + dark translucent fill +
   1px hairline border). State-aware color + motion via [data-state]:
   - loading: accent orange, calm breathing
   - info:    muted white, calm breathing (mid-session swaps, etc.)
   - error:   warn yellow, breathing + soft inset glow
   The base node stays mounted so the slide-in/out transitions on
   .status-bar--visible are uninterrupted by React unmounts. */
.status-bar {
  position: fixed;
  /* Sits in the upper third so it stays visible when the AdvancedDrawer
     is open (the drawer slides up from the bottom and covers the lower
     half on most viewports). Dead-center would be hidden behind it. */
  top: 35%;
  left: 50%;
  /* Hidden state sits a hair below the resting position so the slide-in
     motion lifts it into place. */
  transform: translate(-50%, calc(-50% + 12px));
  padding: 10px 18px;
  text-align: center;
  background: rgba(6, 6, 12, 0.75);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  border: 1px solid rgba(255, 255, 255, 0.15);
  border-radius: 4px;
  font-family: var(--font-mono);
  font-size: 0.78em;
  letter-spacing: 0.32em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.72);
  z-index: 11;
  /* Long messages (e.g. "Track too long — 322s (max 240s). Trim it.")
     wrap onto multiple lines. The bar is centered and clamped at a
     readable width; line-height gives the wrapped lines breathing room.
     overflow-wrap: anywhere covers pathological inputs (URLs, hashes). */
  max-width: min(80vw, 520px);
  white-space: normal;
  overflow-wrap: anywhere;
  line-height: 1.5;
  pointer-events: none;
  opacity: 0;
  transition:
    opacity 200ms ease,
    transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.status-bar--visible {
  opacity: 1;
  transform: translate(-50%, -50%);
}

/* Only the TEXT inside breathes — the frosted-glass surface stays at
   full opacity so the box reads as solid presence while the message
   gently pulses inside it. */
.status-bar__text {
  display: block;
  animation: start-whisper-breathe 2.6s ease-in-out infinite;
}

/* Optional secondary line (e.g. "this might take up to a minute"
   under "Connecting…"). Smaller, dimmer, slightly tighter letter-
   spacing so it reads as a sub-detail rather than a parallel message.
   Inherits the per-state color from .status-bar[data-state] on the
   parent. Negative animation-delay puts it ~half a breath cycle off
   the main line so the two pulse alternately, not in lockstep. */
.status-bar__subtitle {
  display: block;
  margin-top: 5px;
  font-size: 0.7em;
  letter-spacing: 0.22em;
  text-transform: none;
  opacity: 0.7;
  animation: start-whisper-breathe 2.6s ease-in-out infinite -1.3s;
}

.status-bar[data-state="loading"] {
  color: var(--accent);
}

.status-bar[data-state="info"] {
  color: rgba(255, 255, 255, 0.85);
}

.status-bar[data-state="error"] {
  color: var(--warn);
  border-color: rgba(255, 204, 0, 0.45);
  /* Soft inset glow that draws the eye without yelling. Layered with
     a faint outer halo for a halo-ribbon-flavored bloom. */
  box-shadow:
    inset 0 0 0 1px rgba(255, 204, 0, 0.25),
    0 0 24px -6px rgba(255, 204, 0, 0.45);
}

/* ── Desktop-only keyboard shortcut badges ───────────────────────────────── */
@media (pointer: coarse) { .desktop-only { display: none !important; } }
@media (hover: none)     { .desktop-only { display: none !important; } }

/* ── MIDI learn indicator ────────────────────────────────────────────────
   Right-clicking a slider strip or any [data-midi-learn] button puts that
   element into "waiting for MIDI" mode. The dashed warning outline pulses
   between warn-yellow and accent-pink so the operator can tell at a glance
   which control is armed. The next CC / Note message clears the class. */
.midi-learning {
  outline: 2px dashed var(--warn);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
  animation: midi-learn-pulse 0.7s ease-in-out infinite alternate;
}
@keyframes midi-learn-pulse {
  from { outline-color: var(--warn); }
  to   { outline-color: var(--accent); }
}

/* Phase 12 perf — defer layout/paint cost on the collapsed Advanced drawer.
 * content-visibility:auto lets the browser skip rendering work entirely
 * until the drawer scrolls into view (or here, opens). intrinsic-size
 * gives it a placeholder so the page doesn't reflow when it expands. */
.install-sheet:not(.open) .install-sheet-body {
  content-visibility: auto;
  contain-intrinsic-size: 0 320px;
}

/* ============================================================================
   Halo Badge — single floating circular logo+account button mirrored against
   the "Remix Strength" label at the right end of the top ribbon bar. The
   badge inset matches the label inset (24px from the bar's inner edge,
   vertically centered on the bar's mid-line) so the two read as deliberate
   bookends of the same horizontal axis. The four-color conic-gradient ring
   borrows the perimeter ribbon palette and the box-shadow halo pulses with
   --bloom-amount, so the badge breathes alongside the ribbons on every kick.
   ============================================================================ */
.halo-badge {
  position: fixed;
  /* z-index 8: above .desktop-edge-drag (z=7) so the badge is clickable
     even though it now overlaps the top edge bar; still below the Play
     overlay (z=20) so the badge reads as a quiet veiled element on the
     title screen, like the "Remix Strength" label. */
  z-index: 8;
  /* Aligned with the .install-edge-top label so their visible tops match.
     The badge centers a 44px disc that's then orbited by writhing SVG ribbons
     extending ~3px above the bbox; adding bloom halo, the visible top reaches
     ~5–7px above bbox top. Centering the badge bbox on the label mid-line
     made the ribbon top sit ~12px above the label cap-top. We instead
     anchor the bbox top at (mid-line - 10), which lands the visible halo top
     at the same Y as the label's cap-top. Right inset (24px from bar edge)
     mirrors the label's 24px left inset. Safe-area floors for notched devices. */
  top: max(calc(var(--hud-thickness) / 2 - 10px + var(--top-bias, 5px)), env(safe-area-inset-top, 0px));
  right: calc(
    max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-right, 0px))
    + var(--drawer-w-open, 0px)
  );
  width: 44px;
  height: 44px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  /* Allow the writhing SVG ribbons to bleed slightly outside the circle. */
  overflow: visible;
  transition:
    transform 140ms cubic-bezier(.2, .8, .2, 1),
    filter 200ms,
    right 320ms cubic-bezier(.2, .7, .2, 1);
}

/* Plain dark inner backdrop — letting the writhing ribbon ring carry the
   visual focus on its own. (Conic gradient removed for now; can re-layer
   under this if we want the gradient peeking back through.) */
.halo-badge::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: rgba(6, 6, 12, 0.6);
  -webkit-backdrop-filter: blur(6px);
          backdrop-filter: blur(6px);
  transition: background 160ms;
  z-index: 0;
}
.halo-badge:hover {
  transform: scale(1.06);
  filter: brightness(1.12) saturate(1.08);
}
.halo-badge:hover::before {
  background: rgba(6, 6, 12, 0.78);
}
.halo-badge:active { transform: scale(0.98); }
.halo-badge--open {
  filter: brightness(1.18) saturate(1.12);
}

/* Real animated ribbon border — 4 writhing closed paths, one per palette
   color, populated each frame by tickHaloRibbon in the render loop. The
   drop-shadow filter mirrors .install-ribbons so the badge bloom-pulses
   in lockstep with the perimeter ribbons on every kick. */
.halo-badge .halo-ribbons {
  position: absolute;
  inset: -3px;
  width: calc(100% + 6px);
  height: calc(100% + 6px);
  pointer-events: none;
  overflow: visible;
  z-index: 1;
  filter: drop-shadow(0 0 calc(1px + var(--bloom-amount, 0) * 8px) rgba(255, 255, 255, 0.55));
  /* Canvas-rendered (was SVG); stroke-width and opacity from the old CSS
     contract are applied in tickHaloRibbon. */
}

.halo-badge-img {
  position: relative;
  z-index: 2;
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
  /* Inset slightly so the icon doesn't crowd the ribbon ring. */
  transform: scale(0.58);
  opacity: 0.92;
  /* Hamburger glyph reads currentColor from this element. White on the
     dark badge bg, consistent against either ribbon-halo color. */
  color: #fff;
}
.halo-badge:hover .halo-badge-img,
.halo-badge--open .halo-badge-img { opacity: 1; }

/* Floating menu anchored beneath the badge. Tracks the badge's new
   position: badge top (mid-line - 10) + 44px height + 12px gap =
   (--hud-thickness / 2) + 46px. Right offset matches the badge's inset. */
.halo-menu {
  position: fixed;
  top: max(calc(var(--hud-thickness) / 2 + 46px + var(--top-bias, 5px)), calc(env(safe-area-inset-top, 0px) + 56px));
  right: max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-right, 0px));
  min-width: 240px;
  /* z-index 9 — one above the badge (z=8) so the menu paints on top. */
  z-index: 9;
  background: var(--sheet-bg);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-lg);
  padding: 6px 0;
  box-shadow:
    0 18px 50px rgba(0, 0, 0, 0.55),
    0 0 0 1px var(--accent-line),
    0 0 26px rgba(240, 138, 72, 0.18);
  display: flex;
  flex-direction: column;
  font-family: var(--font-mono);
  font-size: 0.78em;
  overflow: hidden;
  transform-origin: top right;
  animation: halo-menu-in 200ms cubic-bezier(.2, .8, .2, 1);
}
@keyframes halo-menu-in {
  from { opacity: 0; transform: translateY(-6px) scale(0.96); }
  to   { opacity: 1; transform: translateY(0)   scale(1); }
}
/* Gradient hairline at the top, echoing the badge it descends from. */
.halo-menu::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  background: var(--dd-gradient);
  pointer-events: none;
}

.halo-menu-email {
  padding: 12px 16px 10px;
  color: var(--text-dim);
  font-size: 0.85em;
  letter-spacing: 0.04em;
  border-bottom: 1px solid var(--frame-line);
  margin-bottom: 4px;
  word-break: break-all;
}

.halo-menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 11px 16px;
  background: transparent;
  border: none;
  text-align: left;
  color: var(--text);
  text-decoration: none;
  font: inherit;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  cursor: pointer;
  transition: background 120ms, color 120ms;
}
.halo-menu-item:hover {
  background: var(--accent-soft);
  color: var(--accent-hover);
}
.halo-menu-item-label { flex: 1; }
.halo-menu-item-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  font-size: 1em;
  color: var(--accent);
}
.halo-menu-item--cta {
  color: var(--accent);
  font-weight: 500;
}
.halo-menu-item--cta:hover { color: var(--accent-hover); }
.halo-menu-item-chip {
  font-size: 0.62em;
  letter-spacing: 0.12em;
  padding: 2px 6px;
  border-radius: 999px;
  background: var(--accent-medium);
  color: var(--accent-hover);
  border: 1px solid var(--accent-line);
}

/* Mobile: keep the badge tappable (44px hits Apple's HIG minimum) and pull
   the menu to a near-full-width sheet that's thumb-friendly. */
@media (max-width: 480px) {
  .halo-menu {
    left: max(14px, env(safe-area-inset-left, 0px));
    right: max(14px, env(safe-area-inset-right, 0px));
    min-width: 0;
  }
}

/* Visual separator inside the halo menu — sets MIDI/keyboard apart from the
   destructive "Sign out" action below it. */
.halo-menu-sep {
  height: 1px;
  margin: 4px 12px;
  background: var(--frame-line);
}

/* ============================================================================
   Config modal — backdrop + sheet for MIDI / keyboard configuration.
   ============================================================================ */
.config-modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 100;
  background: var(--overlay-bg);
  -webkit-backdrop-filter: blur(4px);
          backdrop-filter: blur(4px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  animation: config-fade-in 180ms ease-out;
}
@keyframes config-fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.config-modal {
  position: relative;
  width: min(560px, 100%);
  max-height: min(80vh, 720px);
  background: var(--sheet-bg);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-lg);
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  animation: config-pop-in 220ms cubic-bezier(.2, .8, .2, 1);
}
@keyframes config-pop-in {
  from { opacity: 0; transform: translateY(8px) scale(0.98); }
  to   { opacity: 1; transform: translateY(0)   scale(1); }
}
.config-modal-accent {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  background: var(--dd-gradient);
}

.config-modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 18px 8px;
}
.config-modal-title {
  font-family: var(--font-mono);
  font-size: 0.85em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-strong);
  font-weight: 500;
}
.config-modal-close {
  width: 28px;
  height: 28px;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-dim);
  font-size: 1.4em;
  line-height: 1;
  cursor: pointer;
  transition: color 120ms, background 120ms;
}
.config-modal-close:hover {
  color: var(--accent);
  background: var(--accent-soft);
}

.config-modal-tabs {
  display: flex;
  gap: 2px;
  padding: 0 18px;
  border-bottom: 1px solid var(--frame-line);
}
.config-modal-tab {
  background: transparent;
  border: none;
  padding: 10px 14px;
  font-family: var(--font-mono);
  font-size: 0.7em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-dim);
  cursor: pointer;
  border-bottom: 2px solid transparent;
  transition: color 120ms, border-color 120ms;
}
.config-modal-tab:hover { color: var(--text); }
.config-modal-tab--active {
  color: var(--accent);
  border-bottom-color: var(--accent);
}

.config-modal-body {
  flex: 1;
  overflow-y: auto;
  padding: 14px 18px 18px;
  font-family: var(--font-mono);
  font-size: 0.78em;
}

/* MIDI tab */
.config-midi-status {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  background: rgba(255, 255, 255, 0.02);
  margin-bottom: 12px;
  font-size: 0.92em;
  color: var(--text);
}
.config-midi-status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--text-dim);
}
.config-midi-status--ok   .config-midi-status-dot { background: var(--dd-1); box-shadow: 0 0 8px var(--dd-1); }
.config-midi-status--warn .config-midi-status-dot { background: var(--dd-2); box-shadow: 0 0 8px var(--dd-2); }
.config-midi-status--info .config-midi-status-dot { background: var(--accent); }
.config-midi-status--off  .config-midi-status-dot { background: var(--text-dim); }
.config-midi-status-hint {
  margin-left: auto;
  color: var(--text-dim);
  font-size: 0.85em;
}

.config-midi-table {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  overflow: hidden;
}
.config-midi-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 48px 56px auto;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  border-bottom: 1px solid var(--frame-line);
}
.config-midi-row:last-child { border-bottom: none; }
.config-midi-row--head {
  background: rgba(255, 255, 255, 0.03);
  color: var(--text-dim);
  font-size: 0.85em;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.config-midi-cell-label { color: var(--text); }
.config-midi-cell-kind  { color: var(--text-dim); }
.config-midi-cell-num   {
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  text-align: right;
}
.config-midi-cell-actions {
  display: flex;
  gap: 6px;
  justify-content: flex-end;
}

.config-midi-btn {
  padding: 5px 10px;
  background: transparent;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  color: var(--text);
  font: inherit;
  font-size: 0.92em;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color 120ms, border-color 120ms, background 120ms;
}
.config-midi-btn:hover {
  color: var(--accent);
  border-color: var(--accent);
  background: var(--accent-soft);
}
.config-midi-btn:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}
.config-midi-btn:disabled:hover {
  color: var(--text);
  border-color: var(--frame-line);
  background: transparent;
}
.config-midi-btn--ghost { color: var(--text-dim); }
.config-midi-btn--learning {
  color: var(--accent);
  border-color: var(--accent);
  background: var(--accent-medium);
  animation: config-pulse 1.2s ease-in-out infinite;
}
@keyframes config-pulse {
  50% { box-shadow: 0 0 14px var(--accent-glow); }
}

.config-midi-footer {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 12px;
}

/* Top-of-panel tip — sits above the status row to teach the right-click
   shortcut before users hunt through the table. Subtle accent border keeps
   it readable without competing with the status pill below. */
.config-midi-tip {
  margin: 0 0 12px;
  padding: 8px 10px;
  border-left: 2px solid var(--accent);
  background: var(--accent-soft);
  border-radius: var(--radius-sm);
  color: var(--text);
  font-size: 0.9em;
  line-height: 1.4;
}

/* Keyboard tab */
.config-keyboard-list {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  overflow: hidden;
}
.config-keyboard-row {
  display: grid;
  grid-template-columns: 120px 1fr;
  align-items: center;
  gap: 12px;
  padding: 10px 12px;
  border-bottom: 1px solid var(--frame-line);
}
.config-keyboard-row:last-child { border-bottom: none; }
.config-keyboard-combo {
  display: inline-block;
  padding: 4px 8px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 0.85em;
  letter-spacing: 0.06em;
  text-align: center;
}
.config-keyboard-desc { color: var(--text); }
.config-keyboard-note {
  margin-top: 12px;
  color: var(--text-dim);
  font-size: 0.85em;
  font-style: italic;
}

/* ============================================================================
   Mobile Lite Controls — surfaces inside the Advanced Controls drawer below
   the 768px breakpoint. Two tactile sliders, a seed dice, a single-line
   prompt + Send. Power features live behind "All controls".
   ============================================================================ */
.install-sheet--mobile {
  /* Lite drawer leaves room for the stage + the Remix rail above it.
     Add safe-area-inset-bottom so the drawer (and its handle) sit above
     the iOS home gesture zone. */
  height: 360px;
  bottom: env(safe-area-inset-bottom, 0px);
}
/* Mobile: kill the entire top HUD edge (the wordmark + halo + rail
   carry the brand). Repurpose .install-edge-left as the Remix Strength
   display — same writhing-ribbon system the desktop top edge uses,
   driven by the same --fill (denoise) value. The MobileRemixRail
   component overlays this and provides the drag interaction. */
@media (max-width: 768px) {
  .install-edge-top { display: none; }
  .install-edge-left {
    top: max(96px, calc(env(safe-area-inset-top, 0px) + 80px));
    bottom: calc(
      var(--drawer-handle-h, 52px) +
      env(safe-area-inset-bottom, 0px) + 12px
    );
    width: 56px;
  }
  /* Hide the LoRA-strength label on the side edge in mobile — the rail
     supplies its own "REMIX STRENGTH" label. */
  .install-edge-left .install-edge-label { display: none; }
  /* Bump halo to a bigger, easier-to-tap target on phones, and revert to
     simple corner-inset positioning since the top ribbon bar is hidden on
     mobile (the ribbon system shifts to the left edge there) — there's no
     bar mid-line to mirror against. */
  .halo-badge {
    width: 52px;
    height: 52px;
    top: max(14px, env(safe-area-inset-top, 0px));
    right: max(14px, env(safe-area-inset-right, 0px));
  }
  .halo-menu {
    top: max(78px, calc(env(safe-area-inset-top, 0px) + 78px));
    right: max(14px, env(safe-area-inset-right, 0px));
  }
}
.lite-controls {
  /* Mobile-only performance bay. Pinned to the bottom safe-area and sized
     to fill the lower ~half of the viewport so the operator can see and
     reach the full surface without paging through tabs: track carousel
     up top, the four main faders mid-body, REC + "All controls" at the
     bottom. The visualizer remains visible (dimmed) above through the
     transparent gap. */
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  /* Full-width bay on every mobile size. The previous
   * ``margin-inline: var(--stepper-rail-w)`` kept the bay clear of
   * the edge fader rails, but it shrank the usable surface on
   * tablets / wide phones and left ugly L/R margins. The rails are
   * still mounted underneath at left/right: 0; they show through
   * the gradient bg but don't block touches (the bay sits at
   * z-index 8, above the rails). Safe-area only — env() so notched
   * landscape phones don't push the bay under the chin. */
  margin-inline: 0;
  padding:
    10px
    max(14px, env(safe-area-inset-left, 0px))
    calc(env(safe-area-inset-bottom, 0px) + 12px)
    max(14px, env(safe-area-inset-right, 0px));
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 8px;
  background: linear-gradient(
    to top,
    rgba(6, 6, 12, 0.94) 0%,
    rgba(6, 6, 12, 0.82) 65%,
    rgba(6, 6, 12, 0.62) 100%
  );
  border-top: 1px solid var(--frame-line);
  border-left: 1px solid var(--frame-line);
  border-right: 1px solid var(--frame-line);
  border-radius: var(--radius-md) var(--radius-md) 0 0;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.04),
    0 -4px 14px rgba(0, 0, 0, 0.4);
  -webkit-backdrop-filter: blur(10px);
          backdrop-filter: blur(10px);
  z-index: 8;
  /* Only render on mobile — desktop has the inset Full Controls panel
     and the HeroMacros bay instead. */
  display: none;
}
@media (max-width: 768px) {
  .lite-controls {
    display: flex;
  }
}
/* (No narrow-phone-specific override needed — the main rule above
 * already runs the bay full-width with safe-area padding only.) */

/* Wave 13.4 lite tabs — Mix / Track pill row sitting at the top of
   the bay. Small mono-caps cells with frame-line outline; active cell
   recessed via inset shadow + accent text. */
.lite-tabs {
  display: flex;
  gap: 4px;
  align-self: center;
}
.lite-tab {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 12px 4px;
  background: transparent;
  border: 1px solid var(--frame-line);
  border-radius: 999px;
  color: var(--text-dim);
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  line-height: 1;
  -webkit-appearance: none;
          appearance: none;
  transition: color 140ms, border-color 140ms, background 140ms;
}
.lite-tab:hover {
  color: var(--text);
  border-color: var(--accent-line);
}
.lite-tab--active {
  color: var(--text-strong);
  border-color: var(--accent-line);
  background: rgba(255, 255, 255, 0.04);
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.4);
}
.lite-tab-dot {
  display: inline-block;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: #e84f3d;
  animation: save-pill-pulse 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  .lite-tab-dot { animation: none; opacity: 1; }
}

/* Tab body — horizontal scroll-snap track. Two sections live
   side-by-side; whichever one is snap-aligned is the active tab.
   IntersectionObserver mirrors scroll position back into React
   state. Hide the native scrollbar so the track reads as content,
   not a scroller. */
/* The tab body simply hosts the active section — Mix or Track. The
   previous scroll-snap carousel left both sections rendering on top
   of each other on iOS Safari and let the inner track-carousel swipe
   leak out to the outer scroller; LiteControls now mounts one
   section at a time, so the container just needs to host it. */
.lite-tab-body {
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.lite-tab-section {
  width: 100%;
  box-sizing: border-box;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 4px 4px 2px;
}

.lite-row {
  display: flex;
  align-items: center;
}

/* Track row — single native <select> spanning the full width of the
 * bay. Sits across the top; the picker owns its own padding and
 * chevron, so the wrapper just expands to full width. */
.lite-row--track {
  flex: 0 0 auto;
  width: 100%;
  min-width: 0;
}
.lite-row--track > .lite-track-picker {
  flex: 1 1 auto;
  min-width: 0;
}

.lite-row--main {
  /* Take whatever vertical space is left between the track row and the
     action row — the four faders should breathe on tall phones. */
  flex: 1 1 auto;
  min-height: 0;
  gap: 12px;
  justify-content: space-around;
  padding: 6px 0 4px;
  /* Vertical alignment so the four faders share one baseline. */
  align-items: stretch;
}

.lite-row--main .slider-group {
  flex: 1 1 0;
  min-width: 0;
  /* Each fader column stretches to the full row height so the track
     gets every available pixel — that's where the touch precision lives. */
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Bigger gap than the desktop 6px because the mobile thumb is
   * 28 px tall and its anchor is its center — at max value the thumb
   * pokes ~14 px above the track top, which previously slid right
   * over the slider-label. 18 px clears the half-thumb with a small
   * buffer. */
  gap: 18px;
}
.lite-row--main .slider-group .slider-label {
  font-size: 10px;
  letter-spacing: 0.04em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
.lite-row--main .slider-track {
  width: 44px;
  /* min-height instead of fixed height so the fader stretches with
     viewport. The flex parent gives us the room. */
  flex: 1 1 auto;
  min-height: 140px;
}
/* The visible track + fill are as wide as the thumb so the fader
 * reads as a single chunky column instead of a thin line with a
 * disc floating on it. The thumb still sits centered over the
 * track and the contrast (muted track vs accent-bordered thumb)
 * carries the "this is the handle" affordance. */
/* Pill-rounded top + bottom on the mobile track so the circular
 * thumb sits flush inside the column at the min/max ends (the
 * thumb radius == half the track width, so border-radius: 14px on
 * both the recess and the fill closes the corners exactly). */
.lite-row--main .slider-track::before {
  width: 28px;
  border-radius: 14px;
}
.lite-row--main .slider-fill {
  width: 28px;
  border-radius: 14px;
}
.lite-row--main .slider-thumb {
  width: 28px;
  height: 28px;
  border-width: 2px;
  /* Circular cap — same skeuomorphic gradient + bevel shadows as the
   * desktop rectangular thumb, just rounded. The center-line
   * indicator (.slider-thumb::before) reshapes into a tiny dot below
   * so the cap still surfaces its per-value tint. */
  border-radius: 50%;
}
.lite-row--main .slider-thumb::before {
  /* On the circular mobile thumb, the desktop horizontal indicator
   * line would float untethered across the cap. Replace it with a
   * small accent-tinted center dot — same job (shows the per-value
   * colour), shape that fits a disc. */
  left: 50%;
  right: auto;
  top: 50%;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  transform: translate(-50%, -50%);
}

/* Action row — REC on the left, "More" on the right. Both 44px tall to
   meet the iOS HIG touch-target minimum. */
.lite-row--actions {
  flex: 0 0 auto;
  width: 100%;
  justify-content: space-between;
  gap: 10px;
  padding-top: 4px;
}
.lite-row--actions > * {
  flex: 0 0 auto;
}
.lite-row--actions .lite-all-controls {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 0 14px;
  min-width: 80px;
  height: 44px;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
  background: transparent;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: color 120ms, border-color 120ms, background 120ms;
}
.lite-row--actions .lite-all-controls:hover,
.lite-row--actions .lite-all-controls:active {
  color: var(--text-strong);
  border-color: var(--accent-line);
  background: rgba(255, 255, 255, 0.04);
}
.lite-row--actions .lite-all-controls .lite-all-controls-arrow {
  font-size: 14px;
  line-height: 1;
}

.lite-seed-btn {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  width: 88px;
  min-height: 88px;
  padding: 10px 6px;
  background: transparent;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  color: var(--text);
  cursor: pointer;
  transition: border-color 120ms, background 120ms, box-shadow 120ms;
}
.lite-seed-btn:hover,
.lite-seed-btn:active {
  border-color: var(--accent-line);
  background: var(--accent-soft);
  box-shadow: 0 0 12px var(--accent-glow);
}
.lite-seed-icon {
  font-size: 30px;
  line-height: 1;
}
.lite-seed-value {
  font-family: var(--font-mono);
  font-size: 0.72em;
  color: var(--accent);
}

.lite-row--prompt {
  gap: 8px;
  flex: 0 0 auto;
}
.lite-prompt-input {
  flex: 1 1 auto;
  height: 44px;
  padding: 0 14px;
  background: var(--input-bg);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  color: var(--text);
  font: inherit;
  /* 16px prevents iOS Safari auto-zoom on focus. */
  font-size: 16px;
  outline: none;
  transition: border-color 120ms;
  -webkit-appearance: none;
}
.lite-prompt-input:focus {
  border-color: var(--accent);
}
.lite-send-btn {
  flex: 0 0 auto;
  height: 44px;
  padding: 0 20px;
  background: transparent;
  border: 1px solid var(--accent);
  border-radius: var(--radius-md);
  color: var(--accent);
  font: inherit;
  font-family: var(--font-mono);
  font-size: 0.82em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  cursor: pointer;
  transition: background 120ms, color 120ms, box-shadow 120ms;
}
.lite-send-btn:hover,
.lite-send-btn:active {
  background: var(--accent-soft);
  color: var(--accent-hover);
  box-shadow: 0 0 14px var(--accent-glow);
}

.lite-all-controls {
  align-self: center;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  /* 44x44 minimum tap target. */
  min-height: 44px;
  padding: 12px 20px;
  background: transparent;
  border: none;
  color: var(--text-dim);
  font: inherit;
  font-family: var(--font-mono);
  font-size: 0.74em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  cursor: pointer;
  transition: color 120ms;
}
.lite-all-controls:hover { color: var(--accent); }
.lite-all-controls-arrow {
  transition: transform 120ms;
}
.lite-all-controls:hover .lite-all-controls-arrow {
  transform: translateX(4px);
}
/* Unsaved-changes dot — same pulsing red vocabulary as the desktop
   SavePill dot. Surfaces dirty state at L0 (the always-visible mobile
   strip) without taking up dedicated real estate; user goes
   All controls → Saved tab → Save. */
.lite-all-controls-dot {
  display: inline-block;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: #e84f3d;
  margin: 0 6px;
  flex-shrink: 0;
  animation: save-pill-pulse 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  .lite-all-controls-dot { animation: none; opacity: 1; }
}

/* iOS input — disable browser autofill yellow + match dark theme. */
.lite-prompt-input:-webkit-autofill {
  -webkit-text-fill-color: var(--text);
  -webkit-box-shadow: 0 0 0 1000px var(--input-bg) inset;
}

/* ============================================================================
   Mobile Remix rail — vertical slider on the left edge of the screen, the
   primary mobile interaction surface. Veiled behind the Play overlay (z-6)
   so it reads as ambient chrome until Play is pressed.
   ============================================================================ */
:root { --remix-rail-w: 56px; }

.remix-rail {
  position: fixed;
  left: 0;
  /* Sits between the wordmark area and the drawer handle, with safe-area
     respect on top + bottom. */
  top: max(96px, calc(env(safe-area-inset-top, 0px) + 80px));
  bottom: calc(
    var(--drawer-handle-h, 52px) +
    env(safe-area-inset-bottom, 0px) + 12px
  );
  width: var(--remix-rail-w);
  z-index: 6;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  /* Touch target is the entire rail width; the visual track is narrower
     and centered. The wide hit zone makes the rail forgiving on a phone. */
  padding: 6px 0;
  pointer-events: auto;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}

.remix-rail-label {
  flex: 0 0 auto;
  margin-top: 4px;
  font-family: var(--font-mono);
  font-size: 0.72em;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--text);
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  text-shadow: 0 0 12px var(--accent-glow);
}

/* Invisible drag zone — the visible "remix" is the writhing ribbons of
   .install-edge-left underneath, which fills/animates from the same
   denoise value via --fill (set by useEffect in MobileRemixRail). */
.remix-rail-drag {
  flex: 1 1 auto;
  width: 100%;
  cursor: ns-resize;
  touch-action: none;
  background: transparent;
}

.remix-rail-value {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-size: 0.65em;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  min-width: 3ch;
  text-align: center;
}

/* ============================================================================
   Mobile Full Sheet — opens over the Lite drawer when "All controls" is
   tapped. Bottom tab bar, full-width tile per tab.
   ============================================================================ */
.mobile-sheet {
  position: fixed;
  inset: 0;
  z-index: 90;
  background: var(--sheet-bg);
  display: flex;
  flex-direction: column;
  animation: mobile-sheet-in 260ms cubic-bezier(.2, .8, .2, 1);
  /* iOS safe areas. */
  padding-top: env(safe-area-inset-top, 0px);
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
@keyframes mobile-sheet-in {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: translateY(0); }
}
.mobile-sheet-accent {
  position: absolute;
  top: env(safe-area-inset-top, 0px);
  left: 0;
  right: 0;
  height: 2px;
  background: var(--dd-gradient);
  pointer-events: none;
}

.mobile-sheet-header {
  flex-shrink: 0;
  display: grid;
  grid-template-columns: 44px 1fr 44px;
  align-items: center;
  padding: 12px 14px 8px;
  border-bottom: 1px solid var(--frame-line);
}
.mobile-sheet-back {
  width: 44px;
  height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: none;
  color: var(--text);
  font-size: 20px;
  cursor: pointer;
  border-radius: var(--radius-md);
}
.mobile-sheet-back:hover {
  background: var(--accent-soft);
  color: var(--accent);
}
.mobile-sheet-title {
  font-family: var(--font-mono);
  font-size: 0.85em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-strong);
  font-weight: 500;
  text-align: center;
}
.mobile-sheet-spacer { width: 44px; height: 44px; }

/* All four sections live side-by-side in a horizontal scroll-snap track
   so the user can swipe between Mix / Sound / Prompts / Config. The tab
   pills below mirror whichever section is currently in view. */
.mobile-sheet-track {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  scroll-behavior: smooth;
  scrollbar-width: none;
  overscroll-behavior-x: contain;
}
.mobile-sheet-track::-webkit-scrollbar { display: none; }
.mobile-sheet-section {
  flex: 0 0 100%;
  width: 100%;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: 16px 14px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  box-sizing: border-box;
}
/* Mixer tiles take the full sheet width and stack rather than wrap, so
   each tile gets enough room to label itself even on narrow screens. */
.mobile-sheet-section .mixer-tile {
  width: 100%;
  margin: 0;
  box-sizing: border-box;
}
/* CHANNEL GAINS (8 sliders) and CHANNELS (6 sliders) won't fit at narrow
   widths, so let the row scroll horizontally inside its tile rather than
   crushing the slider widths. */
.mobile-sheet-section .mixer-tile .mixer-channels {
  overflow-x: auto;
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  padding-bottom: 4px;
  flex-wrap: nowrap;
}
.mobile-sheet-section .mixer-tile .mixer-channels::-webkit-scrollbar { display: none; }
.mobile-sheet-section .install-section-operator {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 10px;
}
.mobile-sheet-section .install-section-operator > * { width: 100%; }
@media (max-width: 768px), (max-height: 500px) and (orientation: landscape) {
  .mobile-sheet-section .operator-config-actions {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    align-items: stretch;
  }
  .mobile-sheet-section .operator-config-actions .pause-btn {
    width: 100%;
  }
  .mobile-sheet-section .operator-config-actions [data-dd-tooltip]::after {
    display: none !important;
  }
  .mobile-sheet-section .operator-config-readout {
    position: static;
    display: flex;
    flex-direction: column;
    gap: 4px;
    width: auto;
    height: auto;
    min-height: 56px;
    margin: 2px 0 0;
    padding: 9px 10px;
    overflow: visible;
    clip-path: none;
    white-space: normal;
    border: 1px solid var(--frame-line);
    border-radius: var(--radius-sm);
    background: rgba(8, 8, 14, 0.55);
    color: var(--text-dim);
    font-size: 11px;
    line-height: 1.45;
  }
  .mobile-sheet-section .operator-config-readout-item {
    display: block;
  }
}
/* Stack prompts vertically on mobile — the desktop layout is a single
   row (Prompt A | blend | Prompt B | Send) that crushes textareas to
   ~30px wide at 414px viewport. */
.mobile-sheet-section #prompt-section {
  flex-direction: column;
  gap: 12px;
}
.mobile-sheet-section .prompt-slot { flex: 0 0 auto; }
.mobile-sheet-section .prompt-input {
  /* 16px prevents iOS from auto-zooming on focus. */
  font-size: 16px;
  min-height: 80px;
  width: 100%;
  box-sizing: border-box;
}
.mobile-sheet-section #blend-control {
  gap: 10px;
  padding: 8px 0;
  min-width: 0;
  width: 100%;
}
.mobile-sheet-section #blend-control input[type="range"] { flex: 1 1 auto; }
.mobile-sheet-section .send-prompt-btn {
  min-height: 44px;
  font-size: 14px;
  width: 100%;
}

/* Pill-style tab bar — sliding active background on tap, snappy scale
   feedback so the swipe-to-tap interaction feels lively.
   Horizontally scrollable: the six labels (Core / Styles / Mod /
   Channels / Saved / Config) don't fit on a narrow phone with their
   full text. ``flex: 1 1 0`` used to squeeze them in, which clipped
   the last tab ("Config") off the right edge on small viewports.
   Now each tab is content-sized and the strip scrolls horizontally;
   swipe to reach tabs that don't fit. */
.mobile-sheet-tabs {
  flex-shrink: 0;
  display: flex;
  gap: 4px;
  padding: 8px 12px calc(8px + env(safe-area-inset-bottom, 0px));
  border-top: 1px solid var(--frame-line);
  background: var(--frame);
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none; /* Firefox */
  /* Keep the swipe-to-scroll local — without this, swiping past the
   * last tab chains into the parent and closes the sheet. */
  overscroll-behavior-x: contain;
}
.mobile-sheet-tabs::-webkit-scrollbar { display: none; }
.mobile-sheet-tab {
  /* Sized to fit its label (was ``flex: 1 1 0`` which clipped on
   * narrow phones — see the .mobile-sheet-tabs comment above). */
  flex: 0 0 auto;
  appearance: none;
  background: transparent;
  border: 0;
  padding: 10px 14px;
  border-radius: 999px;
  color: var(--text-dim);
  font-family: var(--font-mono);
  font-size: 0.72em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  cursor: pointer;
  white-space: nowrap;
  transition: color 180ms ease, background 180ms ease, transform 80ms ease;
}
.mobile-sheet-tab:hover { color: var(--text); }
.mobile-sheet-tab--active {
  color: var(--text-strong);
  background: color-mix(in srgb, var(--accent) 20%, transparent);
}
.mobile-sheet-tab:active { transform: scale(0.97); }

/* ─── Recording: The Turntable ─────────────────────────────────────────
   Vinyl record + tonearm metaphor for audio capture. Sits floating above
   the bottom HUD bar at the bottom-right. The grooves are real polar
   curves whose radii are modulated by the live audio mirror — so the
   music is literally etched into the disc as you hear it. The tonearm
   drops onto the disc when you start recording; the disc spins. */

/* Wraps the turntable button + an optional warning bubble. `display:
   contents` keeps the wrapper invisible to layout — the button keeps
   its existing fixed positioning. */
.turntable-wrap { display: contents; }

/* Transient warning anchored to the record buttons. Used for clicks
   that don't make sense yet (e.g. record while still connecting), so
   the StatusBar isn't overwritten with a click-tied message. Sits
   above the vinyl turntable button. */
.rec-warning {
  position: fixed;
  bottom: max(
    calc(var(--hud-thickness) + 106px),
    calc(env(safe-area-inset-bottom, 0px) + 82px)
  );
  right: max(calc(var(--hud-thickness) - 16px), env(safe-area-inset-right, 0px));
  padding: 8px 14px;
  background: rgba(6, 6, 12, 0.85);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  border: 1px solid rgba(255, 204, 0, 0.45);
  border-radius: 4px;
  font-family: var(--font-mono);
  font-size: 0.68em;
  letter-spacing: 0.32em;
  text-transform: uppercase;
  color: var(--warn);
  white-space: nowrap;
  box-shadow:
    inset 0 0 0 1px rgba(255, 204, 0, 0.25),
    0 0 24px -6px rgba(255, 204, 0, 0.45);
  z-index: 9;
  pointer-events: none;
  animation: rec-warning-fade-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes rec-warning-fade-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}

.turntable {
  position: fixed;
  /* Float above the bottom HUD bar: anchor 24px above its top edge. */
  bottom: max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-bottom, 0px));
  /* Right inset is set so the DISC center (not the container right edge)
     visually aligns with the HaloBadge center. The container is 92px wide
     with the disc at left:3 width:54 (disc center 30px from container left,
     62px from container right). Halo center sits at (--hud-thickness + 46)
     from the screen right; matching the disc center to that means container
     right = halo_center_from_right - 62 = (--hud-thickness + 46) - 62 =
     (--hud-thickness - 16). The tonearm pivot ends up ~3px inside the right
     HUD bar's x-range, but they never overlap in y (bar bottom sits 24px
     above the turntable top).

     When the Full Controls sidebar is open the turntable slides inward
     by `--drawer-w-open` so it never gets hidden behind the panel; the
     variable is 0 when closed, the sidebar width when open. */
  right: calc(
    max(calc(var(--hud-thickness) - 16px), env(safe-area-inset-right, 0px))
    + var(--drawer-w-open, 0px)
  );
  width: 92px;
  height: 70px;
  padding: 0;
  border: none;
  background: transparent;
  cursor: pointer;
  z-index: 8;
  overflow: visible;
  /* Subtle hover lift; same ease as HaloBadge for family resemblance. */
  transition: transform 160ms cubic-bezier(.2, .8, .2, 1),
              filter 200ms,
              right 320ms cubic-bezier(.2, .7, .2, 1);
}
.turntable:hover { transform: scale(1.04); filter: brightness(1.08); }
.turntable:active { transform: scale(0.99); }
.turntable:disabled,
.turntable--busy { cursor: progress; opacity: 0.75; }
.turntable:focus-visible { outline: none; }
.turntable:focus-visible .turntable-disc {
  box-shadow: 0 0 0 2px var(--accent), 0 6px 22px rgba(0, 0, 0, 0.6);
}

/* Tactful word label beneath the disc — names the object without
   competing with the skeuomorphic vinyl. Sits in the existing
   container's free space below the disc (disc ends at y=60 of a 70px
   container) and centered on the disc's x (left:3, w:54 → center 30).
   Uses --accent-hover on hover/recording to match the disc's own
   color shift. */
.turntable-caption {
  position: absolute;
  top: 66px; /* 6px below disc bottom — gives the label visual breathing room */
  left: 3px;
  width: 54px; /* same x-extent as the disc; centered text below it */
  text-align: center;
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-dim);
  pointer-events: none;
  transition: color 160ms;
}
.turntable:hover .turntable-caption {
  color: var(--accent-hover);
}
/* Hide the REC label while recording — the .turntable-time elapsed
   counter renders in the same vertical slot and the two would stack.
   Fade for continuity with the surrounding state changes. */
.turntable--recording .turntable-caption,
.turntable--paused .turntable-caption {
  opacity: 0;
}

/* Disc — the rotating element. Holds platter, grooves, label, spindle. */
.turntable-disc {
  position: absolute;
  left: 3px;
  top: 6px;
  width: 54px;
  height: 54px;
  border-radius: 50%;
  /* Stay still while idle; CSS animation toggles on for recording. */
  transform-origin: 50% 50%;
  /* Soft ambient halo that the audio kick punches up. */
  filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.55))
          drop-shadow(0 0 calc(1px + var(--bloom-amount, 0) * 12px)
                      rgba(232, 79, 61, 0.18));
  /* Smooth halt when animation is removed: holds whatever angle. */
}
.turntable--recording .turntable-disc {
  animation: turntable-spin 6s linear infinite;
}
.turntable--paused .turntable-disc {
  animation: turntable-spin 6s linear infinite;
  animation-play-state: paused;
}

/* Platter — the black vinyl base, with a subtle radial gradient so it
   reads as a glossy disc rather than a flat circle. */
.turntable-platter {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background:
    radial-gradient(circle at 35% 30%,
      rgba(255, 255, 255, 0.05) 0%,
      rgba(255, 255, 255, 0) 40%),
    radial-gradient(circle at 50% 50%,
      #15151c 0%,
      #08080d 70%,
      #02020a 100%);
  border: 1px solid rgba(255, 255, 255, 0.08);
  box-shadow:
    inset 0 0 12px rgba(0, 0, 0, 0.6),
    inset 0 0 1px rgba(255, 255, 255, 0.04);
}

/* Grooves — concentric polar polylines drawn each frame by tickTurntable
   onto a 2D canvas (was SVG paths until per-frame setAttribute thrash
   showed up in profiles). Faint warm-on-black so they read as vinyl
   texture, not as glowing rings. The live-waveform wobble (radius
   modulation in tickTurntable) is a shape change, not a color one — it
   still reads at low opacity. Stroke colors and width-vs-recording-state
   live in turntable.ts. */
.turntable-grooves {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  filter: drop-shadow(0 0 calc(0.5px + var(--bloom-amount, 0) * 4px)
                      rgba(232, 79, 61, 0.28));
}

/* Center label — reads as a literal red record button: solid coral fill,
   a thin metallic ring suggesting a pressable bezel, a soft highlight
   from above. The spindle hole sits inside as a subtle dimple instead
   of a separate dot so the whole center reads as one tappable target. */
.turntable-label {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background:
    radial-gradient(circle at 38% 32%,
      rgba(255, 200, 170, 0.55) 0%,
      rgba(255, 200, 170, 0) 38%),
    #e84f3d;
  border: 1px solid rgba(255, 255, 255, 0.18);
  box-shadow:
    0 0 0 1.5px rgba(0, 0, 0, 0.55),
    0 0 8px rgba(232, 79, 61, 0.55),
    inset 0 -1px 2px rgba(0, 0, 0, 0.35),
    inset 0 1px 1px rgba(255, 255, 255, 0.18);
}
.turntable--recording .turntable-label {
  background:
    radial-gradient(circle at 38% 32%,
      rgba(255, 220, 200, 0.6) 0%,
      rgba(255, 220, 200, 0) 38%),
    #ff5a48;
  box-shadow:
    0 0 0 1.5px rgba(0, 0, 0, 0.55),
    0 0 calc(8px + var(--bloom-amount, 0) * 14px) rgba(232, 79, 61, 0.85),
    inset 0 -1px 2px rgba(0, 0, 0, 0.35),
    inset 0 1px 1px rgba(255, 255, 255, 0.22);
}

/* Spindle — recessed dimple in the center of the label. Subtle dot of
   shadow rather than a bright pin, so the label reads as a button. */
.turntable-spindle {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background:
    radial-gradient(circle at 50% 50%,
      rgba(0, 0, 0, 0.65) 0%,
      rgba(0, 0, 0, 0.35) 60%,
      rgba(0, 0, 0, 0) 100%);
  box-shadow: 0 0 0 0.5px rgba(255, 255, 255, 0.12);
}

/* Tonearm — separate SVG so it doesn't rotate with the disc. The <g>
   inside has its origin at the pivot point; CSS rotates it between
   parked (idle) and dropped (recording). */
.turntable-tonearm {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 2;
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.55));
}
.turntable-tonearm-arm {
  /* Pivot point matches the SVG geometry (cx/cy of the pivot circle). */
  transform-origin: 95px 12px;
  /* Idle: needle clearly parked away from the disc — at -26° on a ~48px
     arm, the needle lifts ~21px from the recording position. */
  transform: rotate(-26deg);
  transition: transform 720ms cubic-bezier(.42, 0, .15, 1);
}
.turntable--active .turntable-tonearm-arm {
  transform: rotate(3deg);
}
/* Hover preview: lift the arm a touch so it reads as "ready to drop". */
.turntable:hover .turntable-tonearm-arm {
  transform: rotate(-19deg);
}
.turntable--active:hover .turntable-tonearm-arm {
  transform: rotate(5deg);
}

/* Live timer — coral monospace text below the disc. Doesn't rotate.
   Matches the .turntable-caption "REC" position so the two occupy the
   same visual slot with the same breathing-room from the disc. */
.turntable-time {
  position: absolute;
  left: 0;
  bottom: -6px;
  width: 60px;
  text-align: center;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.08em;
  color: #f08a48;
  opacity: 0.95;
  pointer-events: none;
  text-shadow: 0 0 6px rgba(0, 0, 0, 0.8);
}

@keyframes turntable-spin {
  to { transform: rotate(360deg); }
}

/* Confetti — emits from the center of the disc on save. */
.turntable-confetti {
  position: absolute;
  /* Match the disc center so dots fan from the spindle outward. */
  left: 30px;
  top: 33px;
  width: 0;
  height: 0;
  pointer-events: none;
  z-index: 3;
}
.turntable-confetti-dot {
  position: absolute;
  left: 0;
  top: 0;
  width: 6px;
  height: 6px;
  margin: -3px 0 0 -3px;
  border-radius: 50%;
  opacity: 0;
  animation: turntable-confetti-burst 720ms cubic-bezier(.2, .7, .2, 1) forwards;
}
.turntable-confetti-dot--0  { background: #e84f3d; --x:  46px; --y: -10px; animation-delay: 0ms; }
.turntable-confetti-dot--1  { background: #f08a48; --x:  38px; --y:  28px; animation-delay: 20ms; }
.turntable-confetti-dot--2  { background: #c7b566; --x:  12px; --y:  46px; animation-delay: 40ms; }
.turntable-confetti-dot--3  { background: #3db6be; --x: -24px; --y:  42px; animation-delay: 60ms; }
.turntable-confetti-dot--4  { background: #e84f3d; --x: -44px; --y:  18px; animation-delay: 0ms; }
.turntable-confetti-dot--5  { background: #f08a48; --x: -46px; --y: -14px; animation-delay: 30ms; }
.turntable-confetti-dot--6  { background: #c7b566; --x: -30px; --y: -36px; animation-delay: 80ms; }
.turntable-confetti-dot--7  { background: #3db6be; --x:  -6px; --y: -46px; animation-delay: 50ms; }
.turntable-confetti-dot--8  { background: #e84f3d; --x:  22px; --y: -40px; animation-delay: 10ms; }
.turntable-confetti-dot--9  { background: #f08a48; --x:  48px; --y:  12px; animation-delay: 70ms; }
.turntable-confetti-dot--10 { background: #c7b566; --x:  26px; --y:  40px; animation-delay: 40ms; }
.turntable-confetti-dot--11 { background: #3db6be; --x: -18px; --y: -30px; animation-delay: 90ms; }

@keyframes turntable-confetti-burst {
  0%   { transform: translate(0, 0)   scale(0.4); opacity: 0; }
  20%  { opacity: 1; }
  100% { transform: translate(var(--x), var(--y)) scale(1.05); opacity: 0; }
}

@media (max-width: 768px) {
  .turntable {
    /* Bottom inset matches desktop (hud + 24): LiteControls renders
       inside the drawer (z=50), which fully covers the turntable
       (z=8) when open, so the floating turntable only needs to clear
       the closed-state drawer handle — same clearance the desktop
       value already gives. */
    /* Mobile halo uses a flat 14px corner inset (no bar mid-line to
       mirror against), so the turntable matches that — the disc can't
       fully center-align with the halo on a phone (the disc is 62px
       from the container right, but the halo center sits only ~40px
       from the screen edge), but anchoring both at the same right
       inset keeps the lower-right corner cohesive. */
    right: max(14px, env(safe-area-inset-right, 0px));
  }
}
@media (max-width: 360px) {
  .turntable { width: 80px; height: 60px; }
  .turntable-disc { width: 48px; height: 48px; left: 3px; top: 4px; }
  .turntable-confetti { left: 27px; top: 28px; }
  .turntable-time { width: 54px; font-size: 10px; }
}

/* ─── Inline record toggle (.rec-btn) ──────────────────────────────────
   Composes on top of .pause-btn so it shares shape, size, and hover
   behavior with KIOSK / KBD / Pause. Recording state uses .pause-btn's
   existing .active treatment plus a tiny pulsing dot. */

.rec-btn {
  /* Just enough room for the inline dot when recording. */
  gap: 6px;
}
.rec-btn-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 5px rgba(232, 79, 61, 0.6);
  flex-shrink: 0;
}
.rec-btn--recording .rec-btn-dot {
  animation: rec-btn-blink 1s ease-in-out infinite;
  box-shadow: 0 0 calc(4px + var(--bloom-amount, 0) * 10px) rgba(232, 79, 61, 0.85);
}
.rec-btn--recording {
  /* Reinforce the .active treatment from .pause-btn.active so a recording
     button is unmistakable in a row of similar pills. */
  color: var(--text-strong);
}
.rec-btn--busy { cursor: progress; opacity: 0.75; }
@keyframes rec-btn-blink {
  0%, 100% { opacity: 1; transform: scale(1); }
  50%      { opacity: 0.4; transform: scale(0.85); }
}

/* ─── Recording preview sheet ──────────────────────────────────────────
   Bottom-center pill that rises after stop. Three text buttons keeping
   the lite-send-btn visual language. Audio element uses the browser's
   native controls — no need to reinvent scrubbing UI. */

.recording-preview {
  position: fixed;
  left: 50%;
  bottom: 96px;
  transform: translateX(-50%);
  z-index: 12;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px 16px 12px;
  background: var(--sheet-bg);
  border: 1px solid var(--accent-line);
  border-radius: 14px;
  min-width: 320px;
  max-width: min(440px, calc(100vw - 32px));
  box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55),
              0 0 calc(8px + var(--bloom-amount, 0) * 14px) var(--accent-glow);
  animation: recording-preview-in 320ms cubic-bezier(.2, .8, .2, 1);
}
.recording-preview-header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.04em;
}
.recording-preview-title {
  color: var(--text-strong);
  text-transform: uppercase;
  font-weight: 600;
}
.recording-preview-meta {
  color: var(--text-dim);
  font-size: 11px;
}
.recording-preview-audio {
  width: 100%;
  height: 36px;
  border-radius: 8px;
  background: rgba(0, 0, 0, 0.3);
}
/* Stack: video chip on top (when present), audio waveform underneath.
   The video is the eye-catching artifact users want to verify before
   committing to download; pinning it above the audio puts the more
   informative element first. */
.recording-preview-media {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 8px;
}
.recording-preview-video-chip {
  width: 100%;
  max-height: 180px;
  aspect-ratio: 16 / 9;
  border-radius: 6px;
  border: 1px solid var(--frame-line);
  background: #000;
  object-fit: cover;
  pointer-events: none;
}
/* 3-way segmented control: Video+Audio | Audio | Both. Three equal-width
   buttons share a hairline frame; the active option recesses with an
   inset shadow + brighter foreground (same vocabulary as the drawer
   tab strip). */
.recording-preview-format {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 0;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  overflow: hidden;
}
.recording-preview-format-btn {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  background: transparent;
  border: none;
  border-left: 1px solid var(--frame-line);
  padding: 8px 6px 9px;
  cursor: pointer;
  color: var(--text-dim);
  transition: color 140ms, background 140ms, box-shadow 140ms;
}
.recording-preview-format-btn:first-child {
  border-left: none;
}
.recording-preview-format-btn:hover {
  color: var(--text-strong);
  background: rgba(255, 255, 255, 0.03);
}
.recording-preview-format-btn--active {
  color: var(--text-strong);
  background: rgba(255, 255, 255, 0.05);
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45);
}
.recording-preview-format-btn-label {
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  line-height: 1.2;
}
.recording-preview-format-btn-sublabel {
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-dim);
  line-height: 1.2;
}
.recording-preview-format-btn--active .recording-preview-format-btn-sublabel {
  color: var(--accent);
}
.recording-preview-actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
  flex-wrap: wrap;
}
.recording-preview-btn {
  appearance: none;
  border: 1px solid var(--accent-line);
  background: transparent;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 8px 14px;
  border-radius: 999px;
  cursor: pointer;
  transition: background 160ms ease, color 160ms ease,
              box-shadow 160ms ease, transform 80ms ease;
}
.recording-preview-btn:hover {
  background: var(--accent-soft);
  color: var(--text-strong);
  box-shadow: 0 0 14px var(--accent-glow);
}
.recording-preview-btn:active { transform: scale(0.97); }
.recording-preview-btn--primary {
  background: var(--accent-medium);
  border-color: var(--accent);
  color: var(--text-strong);
}
.recording-preview-btn--primary:hover {
  background: var(--accent);
  color: #0a0a12;
}
.recording-preview-btn--ghost {
  border-color: rgba(255, 255, 255, 0.18);
  color: var(--text-dim);
}

@keyframes recording-preview-in {
  from { opacity: 0; transform: translate(-50%, 12px); }
  to   { opacity: 1; transform: translate(-50%, 0); }
}

@media (max-width: 480px) {
  .recording-preview {
    left: 12px;
    right: 12px;
    bottom: 88px;
    transform: none;
    min-width: 0;
    max-width: none;
    animation-name: recording-preview-in-mobile;
  }
}
@keyframes recording-preview-in-mobile {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* ============================================================================
   AudioSourceCrate — bottom-left "now playing" placard, primary track-picker.
   The placard itself is the click target: shows the current track at rest,
   click to fan out a column of sleeves with a permanent "upload your own"
   sleeve pinned at the bottom (closest to the placard, always reachable
   regardless of scroll). The advanced-controls strip carries an unchanged
   dropdown + upload icon for power users who prefer the dropdown.
   ============================================================================ */

/* Bottom-left dock holding the now-playing placard + always-visible
   upload icon button. The dock owns the fixed position AND the
   collective hover-lift; the placard and upload button merge into a
   single visual frame (no gap, no border between them, only the
   placard carries the brand gradient strip + radii). */
.audio-source-dock {
  position: fixed;
  /* Mirrors the halo badge across the horizontal midline: the same
     right inset as the badge, and a bottom margin equal to the badge's
     TOP margin — so the now-playing dock and the hamburger read as a
     symmetric pair with matching page margins. .save-pill mirrors this
     in the lower-left. */
  bottom: max(
    calc(var(--hud-thickness) / 2 - 10px + var(--top-bias, 5px)),
    env(safe-area-inset-bottom, 0px)
  );
  right: max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-right, 0px));
  z-index: 8;
  /* Only the interactive children — the body when expanded, the bubble
     when collapsed — catch input; the dock's own footprint stays
     click-through (see .audio-source-dock-body / .audio-source-bubble). */
  pointer-events: none;
  display: flex;
  flex-direction: row;
  align-items: stretch;
  gap: 0;
  transition: transform 140ms cubic-bezier(.2, .8, .2, 1);
  /* Brand gradient strip + bloom drop-shadow live on the dock so they
     span placard + upload as one continuous element — no per-child
     gradient seam, no separate shadow halos. */
  filter: drop-shadow(0 0 calc(1px + var(--bloom-amount, 0) * 6px) var(--accent-glow));
}
/* Brand gradient strip — split into two pseudo-elements (one per
   child) so each piece pins to its own child's positioning context.
   Since --dd-gradient is a 180deg gradient compressed into 1px, both
   strips show the same top-stop color and visually meet seamlessly at
   the placard/upload seam. */
/* Hover anywhere on the pair lifts the whole widget together so the
   placard + upload read as one unit, not two. :has() supported in
   all modern evergreen browsers we target. */
.audio-source-dock:has(.audio-source-crate:hover),
.audio-source-dock:has(.audio-source-upload-btn:hover),
.audio-source-dock:has(.audio-source-mic-btn:hover) {
  transform: translateY(-1px);
}
.audio-source-dock:has(.audio-source-crate:active),
.audio-source-dock:has(.audio-source-upload-btn:active),
.audio-source-dock:has(.audio-source-mic-btn:active) {
  transform: translateY(0);
}

/* ── Auto-collapse to a play bubble ──────────────────────────────────
   AudioSourceCrate sets [data-collapsed] on the dock 2s after the
   pointer leaves. The body (placard + upload + mic) cross-fades + folds
   toward the corner as the bubble pops in; hovering the bubble (which
   bubbles the dock's onMouseEnter) clears the flag and expands again. */
.audio-source-dock-body {
  display: flex;
  flex-direction: row;
  align-items: stretch;
  gap: 0;
  pointer-events: auto;
  transform-origin: bottom right;
  transition:
    opacity 0.2s ease,
    transform 0.26s cubic-bezier(.2, .8, .2, 1);
}
.audio-source-dock[data-collapsed] .audio-source-dock-body {
  opacity: 0;
  transform: scale(0.86) translateX(16px);
  pointer-events: none;
}
.audio-source-bubble {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 46px;
  height: 46px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  margin: 0;
  border: none;
  border-radius: 50%;
  background: transparent;
  color: var(--accent);
  cursor: pointer;
  /* Let the writhing ribbon ring bleed outside the circle. */
  overflow: visible;
  /* Hidden while the dock is expanded. */
  opacity: 0;
  transform: scale(0.5);
  transition:
    opacity 0.2s ease,
    transform 0.26s cubic-bezier(.2, .8, .2, 1),
    filter 0.18s ease;
}
/* Dark inner disc — the writhing ribbon canvas is the visible border,
   exactly like .halo-badge::before + .halo-badge .halo-ribbons. */
.audio-source-bubble::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: rgba(6, 6, 12, 0.66);
  -webkit-backdrop-filter: blur(6px);
          backdrop-filter: blur(6px);
  z-index: 0;
}
.audio-source-bubble .halo-ribbons {
  position: absolute;
  inset: -3px;
  width: calc(100% + 6px);
  height: calc(100% + 6px);
  pointer-events: none;
  overflow: visible;
  z-index: 1;
  filter: drop-shadow(
    0 0 calc(1px + var(--bloom-amount, 0) * 8px) rgba(255, 255, 255, 0.55)
  );
}
.audio-source-bubble svg {
  position: relative;
  z-index: 2;
}
.audio-source-bubble:hover {
  filter: brightness(1.1) saturate(1.08);
}
.audio-source-dock[data-collapsed] .audio-source-bubble {
  opacity: 1;
  transform: scale(1);
  pointer-events: auto;
}
.audio-source-crate {
  position: relative; /* positioning context for the brand gradient ::before */
  min-width: 140px;
  max-width: 260px;
  padding: 7px 12px 7px 14px;
  background: rgba(6, 6, 12, 0.72);
  -webkit-backdrop-filter: blur(6px);
          backdrop-filter: blur(6px);
  border: 1px solid var(--frame-line);
  /* The brand gradient ::before IS the top edge — no separate top
     border, otherwise hover (which repaints border to --accent-line)
     would stack a colored 1px line beneath the gradient strip. */
  border-top: none;
  /* Right edge is the shared seam with the upload button — flatten that
     corner so the two pieces sit flush, and keep the right border so
     the seam reads as one 1px frame-line (the upload button drops its
     left border to avoid doubling). */
  border-radius: var(--radius-md) 0 0 var(--radius-md);
  /* Clip the gradient ::before strip to the rounded corners. Without
     this the 1px-tall strip overshoots the top-left curve. */
  overflow: hidden;
  font-family: var(--font-mono);
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 10px;
  cursor: pointer;
  text-align: left;
  /* Drop-shadow lives on the .audio-source-dock parent so it traces
     the merged outline; gradient strip lives on this ::before. */
  transition:
    background 160ms,
    border-color 160ms;
}
.audio-source-crate::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 1px;
  background: var(--dd-gradient);
  pointer-events: none;
  /* Top-left radius only — the right edge meets the upload's strip,
     which owns the top-right radius. */
  border-radius: var(--radius-md) 0 0 0;
}
/* Always-visible upload icon button. Sits flush against the placard's
   right edge — same height, same blur background, same bloom-coupled
   drop-shadow, no separate gradient strip — so the pair reads as one
   frame with an internal seam. The placard owns the brand gradient and
   the left radii; the upload owns the right radii. */
.audio-source-upload-btn,
.audio-source-mic-btn {
  position: relative;
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  min-width: 38px;
  padding: 0 12px 0 10px;
  background: rgba(6, 6, 12, 0.72);
  -webkit-backdrop-filter: blur(6px);
          backdrop-filter: blur(6px);
  border: 1px solid var(--frame-line);
  border-left: none; /* sibling's right border is the seam */
  border-top: none;  /* gradient strip ::before owns the top edge */
  overflow: hidden;
  color: var(--text-dim);
  cursor: pointer;
  transition:
    background 160ms,
    border-color 160ms,
    color 160ms;
}
/* Upload is the MIDDLE button now (placard | upload | mic). Square
   corners — only the placard (left) and mic (right) get rounded. */
.audio-source-upload-btn {
  border-radius: 0;
}
/* Wrapper around the placard + upload-btn pair — the two ways to
 * swap input audio. Serves as the positioning context for the
 * UploadOnboardingHint so its arrow centers above the combined
 * affordance area (not just one button). Mic stays outside this
 * wrapper because it's a separate input mode, not a track-swap.
 * Transparent to layout: inline-flex + align-items: stretch so the
 * inner buttons still fill the dock body vertically. */
.audio-source-track-controls {
  position: relative;
  display: inline-flex;
  align-items: stretch;
  gap: 0;
}
.audio-source-mic-btn {
  border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
/* Mono-caps treatment that matches .audio-source-marquee-label on the
   adjacent placard, so the dock reads as one styled block. Hidden on
   very narrow viewports so the bottom-left dock keeps its compact
   geometry next to the turntable. */
.audio-source-upload-label {
  font-size: 9.5px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: inherit;
  line-height: 1;
  white-space: nowrap;
}
@media (max-width: 480px) {
  .audio-source-upload-btn,
  .audio-source-mic-btn { padding: 0; width: 38px; gap: 0; }
  .audio-source-upload-label { display: none; }
}
.audio-source-upload-btn::before,
.audio-source-mic-btn::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 1px;
  background: var(--dd-gradient);
  pointer-events: none;
}
/* Only the right-most button (mic) gets the rounded top-right
   corner; upload sits in the middle, no rounding. */
.audio-source-mic-btn::before {
  border-radius: 0 var(--radius-md) 0 0;
}
.audio-source-upload-btn:hover,
.audio-source-mic-btn:hover {
  background: rgba(6, 6, 12, 0.85);
  border-color: var(--accent-line);
  color: var(--accent-hover);
}
.audio-source-upload-btn:disabled,
.audio-source-mic-btn:disabled {
  opacity: 0.5;
  cursor: progress;
}
.audio-source-marquee-rows {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1;
  min-width: 0;
}
.audio-source-crate-caret {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 14px;
  height: 14px;
  color: var(--text-dim);
  transition: transform 200ms cubic-bezier(.2, .8, .2, 1), color 160ms;
}
.audio-source-crate:hover .audio-source-crate-caret { color: var(--accent-hover); }
.audio-source-crate--open .audio-source-crate-caret {
  color: var(--accent);
  transform: rotate(180deg);
}
.audio-source-crate:hover {
  background: rgba(6, 6, 12, 0.85);
  border-color: var(--accent-line);
}
.audio-source-crate--open {
  border-color: var(--accent-line);
  background: rgba(6, 6, 12, 0.88);
}

.audio-source-marquee-label {
  font-size: 8.5px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-dim);
}
.audio-source-marquee-name {
  font-size: 11px;
  letter-spacing: 0.06em;
  color: var(--text-strong);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}

/* ── Fan: scrollable column of track sleeves + pinned upload sleeve ──── */
.audio-source-fan {
  position: fixed;
  /* Stack above the placard. Placard bottom anchor + ~38px tall + 8px gap. */
  bottom: max(calc(var(--hud-thickness) + 24px + 38px + 8px),
              calc(env(safe-area-inset-bottom, 0px) + 70px));
  right: max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-right, 0px));
  z-index: 9;
  min-width: 220px;
  max-width: 280px;
  /* Cap so a long catalog scrolls inside the fan rather than off-screen. */
  max-height: min(60vh, 420px);
  background: var(--sheet-bg);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-lg);
  /* Clip the ::before gradient strip to the rounded corners — see
     the design-language Decision Rule on decorative strips. The fan's
     own scrollable child handles its own overflow, and box-shadow
     renders outside the clip box, so the outer glow stays intact. */
  overflow: hidden;
  padding: 6px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-family: var(--font-mono);
  box-shadow:
    0 18px 50px rgba(0, 0, 0, 0.55),
    0 0 0 1px var(--accent-line),
    0 0 26px rgba(240, 138, 72, 0.18);
  transform-origin: bottom right;
  animation: audio-source-fan-in 220ms cubic-bezier(.2, .8, .2, 1);
}
.audio-source-fan::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  background: var(--dd-gradient);
  pointer-events: none;
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
@keyframes audio-source-fan-in {
  from { opacity: 0; transform: translateY(8px) scale(0.96); }
  to   { opacity: 1; transform: translateY(0)   scale(1); }
}
.audio-source-fan-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 2px;
  scrollbar-width: thin;
  scrollbar-color: var(--accent-line) transparent;
}
.audio-source-fan-scroll::-webkit-scrollbar { width: 4px; }
.audio-source-fan-scroll::-webkit-scrollbar-thumb {
  background: var(--accent-line);
  border-radius: 999px;
}
.audio-source-fan-empty {
  padding: 14px 10px;
  color: var(--text-dim);
  font-size: 11px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  text-align: center;
}

.audio-source-sleeve {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 10px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-md);
  color: var(--text);
  font: inherit;
  font-size: 11px;
  letter-spacing: 0.06em;
  text-align: left;
  cursor: pointer;
  transition:
    background 140ms,
    border-color 140ms,
    color 140ms;
  /* Stagger entrance keyed to --idx (set inline per sleeve). */
  animation: audio-source-sleeve-in 220ms cubic-bezier(.2, .8, .2, 1) backwards;
  animation-delay: calc(var(--idx, 0) * 14ms);
}
.audio-source-sleeve:hover {
  background: var(--accent-soft);
  color: var(--accent-hover);
}
.audio-source-sleeve:disabled {
  cursor: progress;
  opacity: 0.7;
}
@keyframes audio-source-sleeve-in {
  from { opacity: 0; transform: translateY(3px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Minimal vinyl-style icon. A thin ring with a tiny center dot — same
   color as the row text, dim by default, accent when current. No
   gradients here so a long list of tracks stays calm. */
.audio-source-sleeve-art {
  position: relative;
  flex-shrink: 0;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 1px solid currentColor;
  color: var(--text-dim);
  display: grid;
  place-items: center;
  opacity: 0.85;
}
.audio-source-sleeve-art::after {
  content: "";
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: currentColor;
}
.audio-source-sleeve-label {
  flex: 1;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.audio-source-sleeve--current {
  background: var(--accent-soft);
  color: var(--accent-hover);
}
.audio-source-sleeve--current .audio-source-sleeve-art {
  color: var(--accent);
  opacity: 1;
}

/* Pinned upload sleeve — always rendered, separated from the scroll list
   above by a thin hairline so it reads as a distinct "you" slot. */
.audio-source-sleeve--upload {
  flex-shrink: 0;
  position: relative;
  margin-top: 2px;
  padding-top: 9px;
  color: var(--accent-hover);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
  font-size: 10px;
}
.audio-source-sleeve--upload::before {
  content: "";
  position: absolute;
  top: 0;
  left: 8px;
  right: 8px;
  height: 1px;
  background: linear-gradient(90deg,
    transparent,
    var(--frame-line) 25%,
    var(--frame-line) 75%,
    transparent);
}
.audio-source-sleeve--upload:hover {
  background: var(--accent-medium);
  color: var(--accent-hover);
}
.audio-source-sleeve-art--upload {
  border: none;
  color: var(--accent);
  width: 14px;
  height: 14px;
  opacity: 1;
}
.audio-source-sleeve-art--upload::after { display: none; }

/* Mobile: shrink the fan to a near-full-width sheet anchored to the
   placard corner. Match the halo-menu mobile pattern. */
@media (max-width: 480px) {
  .audio-source-fan {
    left: max(14px, env(safe-area-inset-left, 0px));
    right: max(14px, env(safe-area-inset-right, 0px));
    max-width: none;
  }
  .audio-source-crate {
    /* Leave room for the upload button + gap (38 + 6 + dock's own left
       inset). The dock's inset is the same `var(--hud-thickness) + 24px`
       used for the right margin, so cap at viewport width minus that
       plus the button. */
    max-width: calc(100vw - 32px - 44px);
  }
}


/* ============================================================================
   Mobile UX overhaul (added 2026-05-07)
   ----------------------------------------------------------------------------
   - Right-edge LoRA blend rail (mobile): mirror of .remix-rail.
   - Lite track carousel: horizontal-scroll fixture chips + upload chip.
   - Lite actions row replaces the prompt row.
   - Hide AudioSourceCrate placard while the mobile Lite drawer is open
     (avoids double track-picker UI; LiteTrackCarousel takes over).
   - Persistent "live" dot indicator in the top-right safe area.
   - Universal press-state polish for coarse pointers.
   See docs/mobile-design-spec.md for the rationale.
   ============================================================================ */

/* ── LoRA blend rail (right edge, mobile) ───────────────────────────────── */
:root { --lora-blend-rail-w: 56px; }

.lora-blend-rail {
  position: fixed;
  right: 0;
  top: max(96px, calc(env(safe-area-inset-top, 0px) + 80px));
  bottom: calc(
    var(--drawer-handle-h, 52px) +
    env(safe-area-inset-bottom, 0px) + 12px
  );
  width: var(--lora-blend-rail-w);
  z-index: 6;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 6px 0;
  pointer-events: auto;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}

.lora-blend-rail-label-top,
.lora-blend-rail-label-bottom {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-size: 0.6em;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text);
  writing-mode: vertical-rl;
  text-shadow: 0 0 12px var(--accent-glow);
  max-height: 14ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.lora-blend-rail-label-top {
  margin-top: 4px;
  /* Reads top-down (label belongs to the top end of the rail). */
  transform: rotate(180deg);
}
.lora-blend-rail-label-bottom {
  margin-bottom: 4px;
  opacity: 0.7;
}

.lora-blend-rail-drag {
  flex: 1 1 auto;
  width: 100%;
  cursor: ns-resize;
  touch-action: none;
  background: transparent;
}

/* ── Lite track picker (native <select>) ─────────────────────────────── */
/* Replaces the previous scroll-snap carousel of chips. Letting the OS
 * draw the picker on tap gives a one-thumb friendly chooser (iOS
 * wheel / Android dialog) with built-in scrolling, search, and
 * accessibility for free. The CSS below dresses the closed-state
 * select to match the bay's mono-caps aesthetic; ``appearance: none``
 * strips the OS chrome but the picker still pops on tap. */
.lite-track-picker {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 2px 2px 0;
}
.lite-track-picker-label {
  font-family: var(--font-mono);
  font-size: 0.66em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-dim);
  padding-left: 2px;
}
.lite-track-picker-field {
  /* Positioning anchor for the chevron — the native select doesn't
   * give us a hook to draw one inside, so it lives as an absolutely
   * positioned span layered on top with pointer-events: none. */
  position: relative;
  display: flex;
}
.lite-track-picker-select {
  flex: 1 1 auto;
  min-height: 48px; /* WCAG 2.5.5 touch-target floor */
  padding: 0 36px 0 14px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  color: var(--text);
  font: inherit;
  font-family: var(--font-mono);
  font-size: 0.78em;
  letter-spacing: 0.04em;
  /* Strip default browser chrome — the chevron span on the right
   * carries the affordance. iOS/Android still open the native picker
   * on tap regardless. */
  -webkit-appearance: none;
     -moz-appearance: none;
          appearance: none;
  cursor: pointer;
  width: 100%;
  text-overflow: ellipsis;
  white-space: nowrap;
  overflow: hidden;
}
.lite-track-picker-select:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-color: var(--accent);
}
.lite-track-picker-select:disabled {
  opacity: 0.6;
  cursor: progress;
}
.lite-track-picker-chevron {
  position: absolute;
  right: 14px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--text-dim);
  pointer-events: none;
  font-size: 0.85em;
}

/* ── Lite actions row (replaces .lite-row--prompt after reshuffle) ──── */
.lite-row--actions {
  flex: 0 0 auto;
  gap: 12px;
  justify-content: space-between;
  align-items: center;
}

/* ── Hide AudioSourceCrate when the mobile Lite drawer is open ──────── */
@media (max-width: 768px) {
  body.drawer-open .audio-source-dock,
  body.drawer-open .audio-source-fan {
    display: none !important;
  }
}

/* ─── UploadOnboardingHint ────────────────────────────────────────────
 *  Sequel to StrengthOnboardingHint — surfaces once the user has moved
 *  Strength, pointing at the bottom-right Upload button (Issue #156).
 *  Same vocabulary as the Strength hint: white italic caption + hand-
 *  drawn arrow gif.
 *
 *  Positioning context: .audio-source-track-controls (a relative-
 *  positioned span wrapping placard + upload). Anchoring above the
 *  pair (not just upload-btn) matches the copy — "Select another
 *  track or upload your own" covers both buttons, so the arrow head
 *  lands at their seam rather than picking one and ignoring the
 *  other.
 *
 *  Layout: hint sits ABOVE the placard/upload pair, horizontally
 *  centered, with text on top and a downward-pointing arrow below
 *  tapping at the seam. ``bottom: 100%`` rests the hint on the
 *  pair's top edge; ``left: 50%`` + ``translate(-50%, -8px)``
 *  centers + lifts. The arrow gif points right by default;
 *  ``rotate: 90deg`` (CSS individual transform — doesn't conflict
 *  with the animated ``transform: translate``) flips it to point
 *  down.
 *
 *  Desktop only — the mobile Lite drawer already surfaces Upload
 *  inline via LiteTrackCarousel; no need for a tutorial overlay.
 *  Also hidden when the dock is collapsed-to-bubble: pointing at an
 *  empty patch of screen reads as broken. (AudioSourceCrate folds
 *  uploadHint.visible into dockActive so this case only fires if the
 *  visibility flag races the collapse animation.) */
.upload-onboarding-hint {
  position: absolute;
  /* Same z-index as the Strength hint — above knob/dock chrome but
   * below the modal stack (.almost-ready-* at 100+). */
  z-index: 6;
  pointer-events: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Real gap so the arrow's resting position (top of its downward
   * bob arc) never collides with the text. The bob translates the
   * arrow DOWN only, so the closest text/arrow approach is at rest. */
  gap: 4px;
  bottom: 100%;
  left: 50%;
  transform: translate(-50%, -8px);
  white-space: nowrap;
  animation: upload-onboarding-hint-in 280ms ease-out;
}
@keyframes upload-onboarding-hint-in {
  from { opacity: 0; transform: translate(-50%, -2px); }
  to   { opacity: 1; transform: translate(-50%, -8px); }
}
@keyframes upload-onboarding-hint-bob {
  0%, 100% { transform: translate(0, 0); }
  /* Bob DOWN toward the button instead of sideways — the arrow is
   * now vertical, so the tap-tap motion follows its axis. */
  50%      { transform: translate(0, 3px); }
}
.upload-onboarding-hint-text {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.05em;
  font-style: italic;
  white-space: nowrap;
  color: #fff;
  text-shadow: 0 0 6px rgba(0, 0, 0, 0.55);
  animation: upload-onboarding-hint-bob 2200ms ease-in-out infinite;
}
.upload-onboarding-hint-arrow {
  flex: 0 0 auto;
  width: 48px;
  height: 48px;
  /* Rotate the right-pointing arrow gif so the head points down at
   * the Upload button below. ``rotate`` is the individual transform
   * property, kept separate from the animated ``transform: translate``
   * so the two compose without overriding each other. */
  rotate: 90deg;
  animation: upload-onboarding-hint-bob 2200ms ease-in-out infinite;
  user-select: none;
  pointer-events: none;
}
/* Hide on touch-only devices — mobile already surfaces Upload via the
 * Lite drawer's track carousel and doesn't need a tutorial overlay. */
@media (hover: none) and (pointer: coarse) {
  .upload-onboarding-hint { display: none; }
}
/* Hide while the dock has collapsed to a bubble — see comment above. */
.audio-source-dock[data-collapsed] .upload-onboarding-hint { display: none; }
@media (prefers-reduced-motion: reduce) {
  .upload-onboarding-hint,
  .upload-onboarding-hint-text,
  .upload-onboarding-hint-arrow {
    animation: none;
  }
}

/* ── Persistent "live" dot ──────────────────────────────────────────── */
.live-indicator {
  position: fixed;
  top: max(12px, env(safe-area-inset-top, 0px));
  right: max(14px, env(safe-area-inset-right, 0px));
  /* Sit above the canvas, below modals. */
  z-index: 7;
  display: flex;
  align-items: center;
  gap: 6px;
  pointer-events: none;
  font-family: var(--font-mono);
  font-size: 0.62em;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-dim);
}
.live-indicator-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #ff3a3a;
  box-shadow: 0 0 8px rgba(255, 58, 58, 0.7);
  animation: live-pulse 1.4s ease-in-out infinite;
}
@keyframes live-pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50%      { opacity: 0.55; transform: scale(0.86); }
}

/* ── Network-quality indicator (Zoom/Meet-style "unstable" pill) ─────
   Subtle bottom-center pill that fades in only when the user is
   probably experiencing stutter (slice arrival jitter, pod stress, or
   a stall). Hidden when healthy via the component returning null.
   Honors the project's single easing, the breathing keyframe, and the
   --warn token. No --bloom-amount coupling. pointer-events disabled
   so the canvas / drawer handle behind it stays interactive. */
.network-indicator {
  position: fixed;
  left: 50%;
  bottom: calc(
    var(--drawer-handle-h, 28px) +
    env(safe-area-inset-bottom, 0px) +
    12px
  );
  transform: translateX(-50%);
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 6px 12px;
  background: var(--overlay-bg);
  -webkit-backdrop-filter: blur(6px);
          backdrop-filter: blur(6px);
  border: 1px solid rgba(255, 204, 0, 0.45);
  border-radius: 4px;
  font-family: var(--font-mono);
  font-size: 0.62em;
  letter-spacing: var(--tracking-wide, 0.18em);
  text-transform: uppercase;
  color: var(--warn);
  pointer-events: none;
  z-index: 11;
  opacity: 0;
  animation:
    network-indicator-fade-in 200ms cubic-bezier(.2, .8, .2, 1) forwards,
    start-whisper-breathe 2.6s ease-in-out infinite 200ms;
  transition: bottom 240ms cubic-bezier(.2, .8, .2, 1);
}
.network-indicator__bars rect {
  fill: currentColor;
}
@keyframes network-indicator-fade-in {
  from { opacity: 0; transform: translate(-50%, 4px); }
  to   { opacity: 1; transform: translate(-50%, 0);   }
}
@media (prefers-reduced-motion: reduce) {
  .network-indicator {
    animation:
      network-indicator-fade-in 200ms cubic-bezier(.2, .8, .2, 1) forwards;
  }
}

/* ── Waveform scrub strip (top-anchored) ─────────────────────────────
   Pinned to the top of the canvas, below the top HUD edge bar. Spans
   horizontally with the same insets the "Remix Strength" ribbon uses,
   so the strip aligns with the same visual frame. Mounted as soon as
   a player exists; visually hidden until the first peak-pass lands. */
.waveform-scrub-box {
  position: fixed;
  top: max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-top, 0px));
  /* Horizontal insets match .schedule-curves-overlay exactly so the
     scrub strip and the curve overlay share the same left/right edges
     (they previously used a hand-tuned 144px/80px that drifted ~50px
     off the overlay). */
  left: max(
    calc(
      var(--hud-thickness) + var(--ribbon-bleed) + var(--remix-hint-extend)
      + 12px + var(--drawer-l-open, 0px)
    ),
    env(safe-area-inset-left, 0px)
  );
  right: max(
    calc(var(--hud-thickness) + var(--ribbon-bleed) + var(--remix-hint-extend) + 12px),
    env(safe-area-inset-right, 0px)
  );
  height: 48px;
  z-index: 4;
  cursor: ew-resize;
  opacity: 0;
  transition:
    opacity 240ms ease-out,
    left 320ms cubic-bezier(.2, .7, .2, 1);
  pointer-events: auto;
  /* Critical on mobile: without this the browser interprets a
   * horizontal touch drag as panning and yanks the pointer event
   * stream away from JS mid-scrub (setPointerCapture is too late to
   * keep ownership). Symptom was a touch-scrub that worked for ~10
   * px then stopped, and loop-drawing was effectively impossible.
   * ``none`` gives the component exclusive control of every gesture
   * on the strip. */
  touch-action: none;
}
.waveform-scrub-box[data-ready="true"] {
  opacity: 1;
}
.waveform-scrub-box:not([data-ready="true"]) {
  pointer-events: none;
}
.waveform-scrub-box .waveform-scrub-bg,
.waveform-scrub-box .waveform-scrub-fg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
}
/* While loop mode is armed the strip drags out a loop band rather than
   scrubbing — reflect that in the cursor. */
.waveform-scrub-box[data-loop-mode="true"] {
  cursor: crosshair;
}
/* Loop control cluster — the LOOP toggle plus, once a region exists, the
   grid / ÷2·×2 / nudge / length / clear controls. Sits top-left of the
   strip; hidden until hover (or until a loop region is set / draw mode is
   armed) so it never sits in the way of a scrub click. Flat per the
   design language. */
.waveform-loop-controls {
  position: absolute;
  top: 4px;
  left: 6px;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 4px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 120ms ease-out;
}
.waveform-scrub-box:hover .waveform-loop-controls,
.waveform-scrub-box[data-has-band="true"] .waveform-loop-controls,
.waveform-scrub-box[data-loop-mode="true"] .waveform-loop-controls {
  opacity: 1;
  pointer-events: auto;
}
.waveform-loop-toggle,
.waveform-loop-btn {
  padding: 2px 8px;
  font: 600 9px/1.4 var(--font-ui, ui-sans-serif), sans-serif;
  letter-spacing: 0.08em;
  border-radius: 4px;
  background: rgba(10, 8, 6, 0.55);
  color: rgba(255, 222, 196, 0.62);
  border: 1px solid rgba(255, 222, 196, 0.22);
  cursor: pointer;
  transition: background 120ms ease-out, color 120ms ease-out,
    border-color 120ms ease-out;
}
.waveform-loop-btn {
  padding: 2px 6px;
}
.waveform-loop-toggle:hover,
.waveform-loop-btn:hover {
  color: rgba(255, 222, 196, 0.95);
  border-color: rgba(255, 222, 196, 0.45);
}
.waveform-loop-toggle[data-active="true"] {
  background: rgba(240, 138, 72, 0.9);
  color: #1a0e05;
  border-color: transparent;
}
.waveform-loop-clear {
  color: rgba(255, 180, 180, 0.6);
}
.waveform-loop-clear:hover {
  color: rgba(255, 200, 200, 0.95);
  border-color: rgba(255, 160, 160, 0.5);
}
.waveform-loop-len {
  font: 600 9px/1.4 var(--font-ui, ui-sans-serif), sans-serif;
  letter-spacing: 0.04em;
  color: rgba(255, 222, 196, 0.7);
  padding: 0 2px;
  white-space: nowrap;
  user-select: none;
}

/* Mobile waveform strip. Sits below the DEMON brand mark and inside the
   side fader rails. Slim height keeps the scrub-line UX while leaving
   the visualizer + control surface room to breathe. The desktop CSS
   uses --hud-thickness offsets that are zero on mobile (no HUD frame
   on phones) so we re-anchor from the top instead. The brand mark
   itself sits at top:18 + height:42 = bottom 60, so 70 leaves a 10px
   gap. */
@media (max-width: 768px) {
  /* On mobile the scrub strip fills the screen between the brand
   * header and the slider bay rather than sitting as a thin line at
   * the top — gives the user a generous scrub target and turns the
   * waveform itself into the primary visual instead of a hidden
   * detail. Anchored top+bottom (no fixed height) so it stretches
   * with viewport: top below the brand mark, bottom above the
   * lite-controls bay which occupies the lower ~half of the
   * viewport. Full width — no rail clearance — matching the bay's
   * full-width treatment. */
  .waveform-scrub-box {
    top: max(env(safe-area-inset-top, 0px) + 70px, 70px);
    left: max(env(safe-area-inset-left, 0px), 8px);
    right: max(env(safe-area-inset-right, 0px), 8px);
    /* Anchor the bottom to the actual top edge of the LiteControls
     * bay (var written by LiteControls' ResizeObserver). Fallback to
     * 50vh for the brief moment before the bay measures itself or
     * if LiteControls isn't mounted for some reason. +8 px breathing
     * gap so the strip never visually touches the bay. */
    bottom: calc(var(--lite-controls-h, 50vh) + 8px);
    height: auto;
    /* Inner breathing room — the canvases (position:absolute;
     * inset:0) anchor to the padding box, so this pulls the
     * waveform graphic away from the container edges. Equal
     * top/bottom keeps the waveform vertically centered. */
    padding: 18px 14px;
    box-sizing: border-box;
    /* Sit above the install-stage background but below modals and the
       LiteControls bay. */
    z-index: 5;
  }
  /* Mobile sliders are touch-only — the loop cluster's hover-reveal would
     never fire, so keep it permanently visible (faint until armed). */
  .waveform-scrub-box .waveform-loop-controls {
    opacity: 0.85;
    pointer-events: auto;
  }
}

/* ── Custom tooltip (replaces native `title=` for hover-visible UI) ──
   Opt-in via `data-dd-tooltip="..."`. The element must have
   `position: relative` (most of our chrome already does because of
   per-element ::before strips) for the pseudo to anchor correctly.

   Default placement: ABOVE the element. Override per-element with
   `data-dd-tooltip-pos="below"` when the element lives at the top
   of the viewport (LibraryButton, HaloBadge, etc.) where a tooltip
   above would clip off-screen.

   Accessibility: keep `aria-label` on the element alongside
   `data-dd-tooltip` — screen readers read the aria-label; this
   pseudo is decorative (aria-hidden by virtue of being a pseudo).

   Design: same vocabulary as .halo-menu / .audio-source-fan —
   sheet bg, frame-line border, top brand-gradient hairline, mono
   caps with --tracking-wide. Single project easing. 200ms hover
   delay so the tooltip doesn't flicker on accidental cursor passes. */
[data-dd-tooltip]::after {
  content: attr(data-dd-tooltip);
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%) translateY(4px);
  padding: 5px 10px 4px;
  background: var(--sheet-bg);
  border: 1px solid var(--frame-line);
  border-top: 2px solid transparent;
  background-image:
    linear-gradient(var(--sheet-bg), var(--sheet-bg)),
    var(--dd-gradient);
  background-origin: padding-box, border-box;
  background-clip: padding-box, border-box;
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-strong);
  white-space: nowrap;
  pointer-events: none;
  z-index: 100;
  opacity: 0;
  visibility: hidden;
  box-shadow:
    0 6px 18px rgba(0, 0, 0, 0.45),
    0 0 14px rgba(240, 138, 72, 0.15);
  transition:
    opacity 140ms cubic-bezier(.2, .8, .2, 1),
    visibility 0s linear 140ms,
    transform 140ms cubic-bezier(.2, .8, .2, 1);
}
[data-dd-tooltip][data-dd-tooltip-pos="below"]::after {
  bottom: auto;
  top: calc(100% + 8px);
  transform: translateX(-50%) translateY(-4px);
}
/* Wide variant for descriptive tooltips (param explanations etc.).
   Allows wrapping and caps the width so long sentences don't bleed
   across the viewport like the default nowrap labels would. */
[data-dd-tooltip][data-dd-tooltip-wide]::after {
  white-space: normal;
  max-width: 280px;
  line-height: 1.45;
  padding: 7px 11px 6px;
  text-transform: none;
  letter-spacing: 0.04em;
  font-size: 10px;
}
/* Hero-bay controls render their wide tooltip via the React
   `<HeroMacrosTooltip/>` component (title-then-body chip, matching
   DrawerHelpBar / HudHelpReadout). Suppress the CSS `::after` pseudo
   inside `.hero-macros` so it doesn't double-show with the React
   tooltip. The compact non-wide variant (e.g. SeedKnob's
   "Randomize seed (right-click to MIDI-learn)") still uses the
   pseudo since it doesn't need the two-line format. */
.hero-macros [data-dd-tooltip][data-dd-tooltip-wide]::after {
  display: none !important;
}

/* React-rendered hero-bay tooltip — mirrors the chip used by
   DrawerHelpBar / HudHelpReadout so the visual vocabulary stays
   consistent across the three surfaces. Position is set inline by the
   component (fixed top/left, transformed so the bottom-center anchors
   above the hovered control). */
.hero-macros-tooltip {
  position: fixed;
  z-index: 100;
  pointer-events: none;
  max-width: 320px;
  padding: 8px 12px 10px;
  /* Plain dark sheet, same vocabulary as the DrawerHelpBar — no
     brand-gradient hairline, no orange glow. The accent color shows
     up only on the title text inside. */
  background: rgba(8, 8, 14, 0.92);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
}
.hero-macros-tooltip-title {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: var(--tracking-bar);
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 4px;
}
.hero-macros-tooltip-text {
  font-size: 11px;
  line-height: 1.45;
  color: var(--text-strong);
  margin: 0;
}
@media (pointer: coarse) {
  /* Hover doesn't exist on touch — the React component itself only
     fires on pointer move, but defensively hide if it somehow renders. */
  .hero-macros-tooltip { display: none !important; }
}
[data-dd-tooltip]:hover::after,
[data-dd-tooltip]:focus-visible::after {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
  /* 200ms delay so accidental passes don't flicker the tooltip. */
  transition:
    opacity 140ms cubic-bezier(.2, .8, .2, 1) 200ms,
    visibility 0s linear 200ms,
    transform 140ms cubic-bezier(.2, .8, .2, 1) 200ms;
}
/* Disable on touch — no real hover, and the active state ::after
   would briefly flash on tap. Accessibility-equivalent info is in
   the element's aria-label. */
@media (pointer: coarse) {
  [data-dd-tooltip]::after { display: none !important; }
}

/* ── Universal press state on coarse pointers ───────────────────────── */
@media (pointer: coarse) {
  button:not(:disabled):active {
    transform: scale(0.97);
  }
}

/* ============================================================================
   Mobile UX revision (2026-05-07, follow-up)
   ----------------------------------------------------------------------------
   - Stepper rails replace the drag rails on mobile (up/down arrow buttons).
   - Portrait lock overlay on phones in portrait orientation.
   - RecordButton moves to top-left on mobile (away from drawer/halo).
   - Halo menu gets max-height + scroll so it never clips on small phones.
   See docs/mobile-design-spec.md.
   ============================================================================ */

/* ── Stepper rails (left + right edges, mobile) ─────────────────────────── */
/* The writhing ribbon canvas under .install-edge-{side} provides the
   visible affordance — the rail itself is two transparent tap zones with
   small chevrons at each end (≈the head of the ribbon). The label sits
   centered between the zones. No numeric readout, no separate hint. */
:root { --stepper-rail-w: var(--remix-rail-w, 64px); }

.stepper-rail {
  position: fixed;
  top: max(96px, calc(env(safe-area-inset-top, 0px) + 60px));
  /* Generous clearance above the Advanced Controls drawer handle so the
     bottom-decrease tap zone can't be confused for the drawer toggle.
     drawer-handle-h (52px) + safe-area-bottom + 36px buffer keeps the
     bottom chevron well clear of the handle's hit area. */
  bottom: calc(
    var(--drawer-handle-h, 52px) +
    env(safe-area-inset-bottom, 0px) + 36px
  );
  width: var(--stepper-rail-w);
  z-index: 6;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  pointer-events: auto;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}
.stepper-rail--left {
  left: max(0px, env(safe-area-inset-left, 0px));
}
.stepper-rail--right {
  right: max(0px, env(safe-area-inset-right, 0px));
}

/* Each zone is half the rail's vertical real estate — generous touch
   target. Backgrounds are transparent so the ribbon canvas underneath
   stays visible. The chevron is the only visible affordance. */
.stepper-rail-zone {
  flex: 1 1 0;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  background: transparent;
  border: none;
  padding: 0;
  margin: 0;
  color: var(--text);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: color 120ms;
}
.stepper-rail-zone--up { align-items: flex-start; padding-top: 8px; }
/* Inset the bottom chevron + tap zone away from the drawer handle so the
   user's thumb can't land on the wrong control. */
.stepper-rail-zone--down { align-items: flex-end; padding-bottom: 14px; }
.stepper-rail-zone:hover { color: var(--accent); }
.stepper-rail-zone:active .stepper-rail-chevron {
  transform: scale(1.15);
  color: var(--accent);
  filter: drop-shadow(0 0 8px var(--accent-glow));
}

.stepper-rail-chevron {
  display: block;
  opacity: 0.85;
  transition: transform 120ms, color 120ms, filter 120ms;
  filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.6));
}

/* "Drag to start" gate on the mobile remix rail. The sideways stepper
   label is too subtle to act as a prompt, so the up chevron itself
   pulses to read as the next action. Cleared the moment the user
   crosses denoise > 0 (component drops `data-gate`). */
.stepper-rail-zone[data-gate="up"] .stepper-rail-chevron {
  opacity: 1;
  color: var(--accent);
  filter: drop-shadow(0 0 12px var(--accent-glow));
  animation: stepper-rail-gate-pulse 1.4s ease-in-out infinite;
}
@keyframes stepper-rail-gate-pulse {
  0%, 100% { transform: translateY(0) scale(1); }
  50% { transform: translateY(-5px) scale(1.18); }
}

.stepper-rail-readout {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  /* Pin in the middle between the two zones. */
  padding: 12px 0;
  pointer-events: none;
}
.stepper-rail-label {
  font-family: var(--font-mono);
  font-size: 0.6em;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text);
  writing-mode: vertical-rl;
  text-shadow: 0 0 12px var(--accent-glow);
  /* Read top-down so the word's reading order matches the way the rail
     extends (top label at top of rail when interpreted vertically). */
  transform: rotate(180deg);
  white-space: nowrap;
}
/* Per-chevron labels rendered next to the up/down arrows when a stepper
 * needs a directional cue (e.g. LoRA Blend pairs each chevron with a
 * track name so the user reads "tap up to get more <A>"). Positioned
 * absolutely just inside the screen edge of the rail and overflowing
 * inward — the rail's natural overflow:visible allows this. */
.stepper-rail-zone { position: relative; }
.stepper-rail-zone-label {
  position: absolute;
  font-family: var(--font-mono);
  font-size: 0.62em;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text);
  white-space: nowrap;
  pointer-events: none;
  /* Strong shadow so the label stays legible over the busy stage (the
   * graph, ribbons, and audio-reactive bloom can all sit behind it). */
  text-shadow:
    0 0 10px rgba(0, 0, 0, 0.95),
    0 0 4px rgba(0, 0, 0, 0.95);
}
.stepper-rail--right .stepper-rail-zone-label {
  /* Sit just inside the rail's left edge — visible on the screen
   * interior side, away from the screen edge where the chevron lives. */
  right: 100%;
  margin-right: 6px;
  text-align: right;
}
.stepper-rail--left .stepper-rail-zone-label {
  left: 100%;
  margin-left: 6px;
}
/* Vertically align with the chevron in each zone. The up-chevron sits
 * at padding-top: 8px; down-chevron at padding-bottom: 14px. */
.stepper-rail-zone--up .stepper-rail-zone-label {
  top: 16px;
}
.stepper-rail-zone--down .stepper-rail-zone-label {
  bottom: 22px;
}

/* ── Fader rails (Wave 13.2) ─────────────────────────────────────────
   Mobile edge rails — one vertical drag track per side. Replaces the
   prior up/down stepper buttons with proper drag-to-value behavior.
   Top sublabel, label (vertical), track + fill + cap, bottom sublabel. */
.fader-rail {
  position: fixed;
  top: max(96px, calc(env(safe-area-inset-top, 0px) + 60px));
  bottom: calc(
    var(--drawer-handle-h, 0px) +
    env(safe-area-inset-bottom, 0px) + 90px
  );
  width: var(--stepper-rail-w);
  z-index: 6;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 6px 0 8px;
  pointer-events: auto;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
  color: var(--text-dim);
}
.fader-rail--left {
  left: max(0px, env(safe-area-inset-left, 0px));
}
.fader-rail--right {
  right: max(0px, env(safe-area-inset-right, 0px));
}
.fader-rail-sublabel {
  font-family: var(--font-mono);
  font-size: 8px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-dim);
  text-align: center;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  padding: 0 2px;
  flex: 0 0 auto;
}
.fader-rail-label {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--text-dim);
  /* Vertical text — runs top→bottom along the rail. */
  writing-mode: vertical-rl;
  text-orientation: mixed;
  transform: rotate(180deg);
  flex: 0 0 auto;
  padding: 4px 0;
}
.fader-rail-track {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  width: 8px;
  cursor: ns-resize;
  touch-action: none;
}
.fader-rail-track::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to right,
    rgba(0, 0, 0, 0.6) 0%,
    rgba(0, 0, 0, 0.45) 50%,
    rgba(255, 255, 255, 0.04) 100%
  );
  border-radius: 4px;
  box-shadow:
    inset 1px 0 0 rgba(0, 0, 0, 0.7),
    inset -1px 0 1px rgba(255, 255, 255, 0.05);
}
.fader-rail-fill {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  background: linear-gradient(
    to top,
    color-mix(in srgb, var(--accent) 70%, #000) 0%,
    var(--accent) 100%
  );
  border-radius: 4px;
  pointer-events: none;
  transition: height 70ms linear;
}
.fader-rail-cap {
  position: absolute;
  left: 50%;
  width: 32px;
  height: 12px;
  background: linear-gradient(
    to bottom,
    rgb(78, 84, 96) 0%,
    rgb(42, 46, 54) 48%,
    rgb(18, 20, 24) 100%
  );
  border-radius: 3px;
  transform: translate(-50%, 50%);
  pointer-events: none;
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.65),
    0 0 0 1px rgba(0, 0, 0, 0.55),
    inset 0 1px 0 rgba(255, 255, 255, 0.15),
    inset 0 -1px 0 rgba(0, 0, 0, 0.5);
  transition: bottom 70ms linear;
}
.fader-rail-cap::before {
  content: "";
  position: absolute;
  left: 3px;
  right: 3px;
  top: 50%;
  height: 1px;
  background: var(--accent);
  transform: translateY(-50%);
  border-radius: 1px;
  box-shadow: 0 0 4px var(--accent);
}
/* pulseUp affordance — pulse the top portion of the track so the user
   reads it as "drag the cap upward to start". Lives on the wrapper
   when data-gate="up" is set. */
@keyframes fader-rail-pulse-top {
  0%, 100% { box-shadow: inset 0 0 0 0 transparent; }
  50%      { box-shadow: inset 0 14px 12px -8px var(--accent); }
}
.fader-rail[data-gate="up"] .fader-rail-track::before {
  animation: fader-rail-pulse-top 1.8s ease-in-out infinite;
}

/* Hide the perimeter writhe ribbons + edge labels on mobile. The new
   stepper rails own the label slot, and the ribbons are visual noise on
   a phone-sized canvas — they reduce contrast with the chevrons and
   make the stage feel cluttered. JS bindings (data-bar, --fill writes
   in useEdgeLoraBinding / steppers) still work because display:none
   leaves the DOM nodes in place. */
@media (max-width: 768px), (max-height: 500px) and (orientation: landscape) {
  .install-edge {
    display: none;
  }
}

/* ── Portrait lock overlay (phones in portrait only) ───────────────────── */
.portrait-lock {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 200; /* above everything else, including modals */
  background: rgba(6, 6, 12, 0.96);
  -webkit-backdrop-filter: blur(8px);
          backdrop-filter: blur(8px);
  align-items: center;
  justify-content: center;
  padding: max(24px, env(safe-area-inset-top, 0px))
           max(24px, env(safe-area-inset-right, 0px))
           max(24px, env(safe-area-inset-bottom, 0px))
           max(24px, env(safe-area-inset-left, 0px));
  text-align: center;
  color: var(--text);
}
/* Wave 13.5: portrait lock retired. The mobile mixer now fits a
   portrait viewport (tabbed bay + swipe between MIX/TRACK), so we
   no longer force the user to rotate. The .portrait-lock element
   stays display:none always (could be remounted by re-adding this
   block if a future flow needs it). */
.portrait-lock-card {
  max-width: 320px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}
.portrait-lock-icon {
  color: var(--accent);
  filter: drop-shadow(0 0 12px var(--accent-glow));
  animation: portrait-rotate 2.4s ease-in-out infinite;
}
@keyframes portrait-rotate {
  0%, 30% { transform: rotate(0deg); }
  60% { transform: rotate(90deg); }
  100% { transform: rotate(90deg); opacity: 0.85; }
}
.portrait-lock-title {
  font-family: var(--font-mono);
  font-size: 1.1em;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}
.portrait-lock-body {
  font-size: 14px;
  line-height: 1.5;
  color: var(--text-dim);
}

/* ── RecordButton repositioned to top-left on phones ───────────────────── */
@media (max-width: 768px), (max-height: 500px) and (orientation: landscape) {
  .turntable {
    /* Flip from bottom-right to top-left. Top inset matches halo-badge so
       the two corner anchors visually balance. */
    top: max(14px, env(safe-area-inset-top, 0px));
    left: max(14px, env(safe-area-inset-left, 0px));
    /* Cancel the desktop right/bottom anchors. */
    right: auto;
    bottom: auto;
    /* Slightly shrunk so it doesn't crowd the left rail. */
    width: 78px;
    height: 60px;
  }
  .turntable-disc { width: 48px; height: 48px; }
  /* Warning bubble sits below the button instead of above. */
  .rec-warning {
    top: calc(100% + 6px);
    bottom: auto;
  }
}

/* ── Halo menu: cap height + scroll on mobile so items never clip ─────── */
@media (max-width: 768px), (max-height: 500px) and (orientation: landscape) {
  .halo-menu {
    /* Top of menu sits at ~78px from the top; allow it to fill the
       rest of the viewport minus drawer-handle clearance + safe-area
       bottom inset. dvh keeps this stable across address-bar collapse. */
    max-height: calc(
      100dvh - 88px
      - var(--drawer-handle-h, 52px)
      - env(safe-area-inset-bottom, 0px)
    );
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
  }
}

/* ============================================================================
   Landscape-phone tuning for Lite drawer + All Controls sheet
   ----------------------------------------------------------------------------
   On a landscape phone (~393px tall), the desktop drawer min-height of
   380px covers the entire viewport. Reduce so users can still see the
   stage when the drawer is open. Compress slider tracks to fit the
   shorter drawer. Center the All-Controls tiles instead of left-aligning
   them in the wide horizontal space.
   ============================================================================ */
@media (max-height: 500px) and (orientation: landscape) {
  :root {
    /* clamp: min 220 / pref 70vh / max 280. Leaves ~115px of stage
       visible on a 393px tall phone (Tap-to-Begin overlay still fits). */
    --drawer-h: clamp(220px, 70vh, 280px);
  }
  /* The default .install-sheet--mobile rule hardcodes height: 360px,
     which fully covers a 393px-tall landscape phone. Override here. */
  .install-sheet--mobile {
    height: var(--drawer-h);
  }

  /* Wave 13.5: the landscape-phone grid override is retired. The new
     tabbed bay (column flex with scroll-snap tab body) handles
     landscape AND portrait via the same layout. */
}

/* Center mixer tiles + their slider rows inside the All Controls sheet so
   small tiles (MAIN with 2 sliders, ENGINE with 4) don't sit lonely on the
   left of an 800px-wide section. CHANNEL_GAINS / CHANNELS still scroll
   horizontally when their 8 / 6 sliders exceed the container — center
   alignment doesn't break scroll. */
.mobile-sheet-section .mixer-channels {
  justify-content: center;
}
/* Padding bottom on the section so the last bit of content (e.g. Sign Out
   in the halo menu, the prompt Send button) clears the tab bar instead
   of sitting flush against it. */
.mobile-sheet-section {
  padding-bottom: 24px;
}

/* ─────────────────────────────────────────────────────────────────────
   Almost Ready upload-confirm dialog
   Pattern-matches .config-modal — same backdrop/animation/accent — but
   sized smaller (≈420px) since the body has just a trim notice + key
   chooser instead of full tabs.
   ───────────────────────────────────────────────────────────────────── */
.almost-ready-backdrop {
  position: fixed;
  inset: 0;
  z-index: 110;
  background: var(--overlay-bg);
  -webkit-backdrop-filter: blur(4px);
          backdrop-filter: blur(4px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  animation: config-fade-in 180ms ease-out;
}
.almost-ready-modal {
  position: relative;
  width: min(540px, 100%);
  max-height: min(80vh, 640px);
  background: var(--sheet-bg);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-lg);
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  animation: config-pop-in 220ms cubic-bezier(.2, .8, .2, 1);
}
.almost-ready-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 18px 6px;
}
.almost-ready-title {
  font-family: var(--font-mono);
  font-size: 0.85em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-strong);
  font-weight: 500;
  margin: 0;
}
.almost-ready-body {
  flex: 1;
  overflow-y: auto;
  padding: 6px 18px 14px;
  font-family: var(--font-mono);
  font-size: 0.78em;
  color: var(--text);
}
.almost-ready-filename {
  margin: 0 0 10px;
  padding: 6px 8px;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  background: rgba(255, 255, 255, 0.02);
  color: var(--text-dim);
  font-size: 0.92em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.almost-ready-trim-msg {
  margin: 0 0 12px;
  padding: 8px 10px;
  border: 1px solid var(--accent);
  border-radius: var(--radius-sm);
  background: var(--accent-soft);
  color: var(--text);
  font-size: 0.92em;
  line-height: 1.45;
}
/* Step indicator — two bars; the active step's bar is accent-filled. */
.almost-ready-steps {
  display: flex;
  gap: 6px;
  margin: 10px 0 0;
}
.almost-ready-step-dot {
  width: 24px;
  height: 3px;
  border-radius: 2px;
  background: var(--frame-line);
  transition: background 160ms ease;
}
.almost-ready-step-dot.is-active { background: var(--accent); }
.almost-ready-step-head {
  margin: 14px 0 10px;
}
.almost-ready-step-num {
  display: block;
  font-size: 0.82em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-dim);
}
.almost-ready-step-title {
  margin: 3px 0 0;
  font-size: 1.18em;
  font-weight: 500;
  color: var(--text-strong);
  letter-spacing: 0.02em;
}

/* Step 1 — selectable source cards (one per inference source). */
.almost-ready-cards {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.almost-ready-card {
  display: flex;
  align-items: flex-start;
  gap: 11px;
  width: 100%;
  text-align: left;
  padding: 12px 13px;
  background: rgba(255, 255, 255, 0.02);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-family: inherit;
  transition: border-color 130ms ease, background 130ms ease;
}
.almost-ready-card:hover { border-color: var(--accent-line); }
.almost-ready-card.is-selected {
  border-color: var(--accent);
  background: var(--accent-soft);
}
.almost-ready-card-radio {
  flex: none;
  width: 15px;
  height: 15px;
  margin-top: 1px;
  border-radius: 50%;
  border: 1.5px solid var(--frame-line);
  transition: border-color 130ms ease, background 130ms ease;
}
.almost-ready-card:hover .almost-ready-card-radio {
  border-color: var(--accent-line);
}
.almost-ready-card.is-selected .almost-ready-card-radio {
  border-color: var(--accent);
  background: radial-gradient(circle, var(--accent) 0 4px, transparent 5px);
}
.almost-ready-card-text {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}
.almost-ready-card-title {
  color: var(--text-strong);
  font-weight: 500;
}
.almost-ready-card.is-selected .almost-ready-card-title {
  color: var(--accent);
}
.almost-ready-card-hint {
  color: var(--text-dim);
  font-size: 0.92em;
  line-height: 1.4;
}

/* Step 2 — key / time-signature fields. */
.almost-ready-field {
  display: flex;
  flex-direction: column;
  gap: 5px;
  margin: 0 0 14px;
}
.almost-ready-field:last-child { margin-bottom: 0; }
.almost-ready-field-label {
  font-size: 0.82em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-dim);
}
.almost-ready-select {
  width: 100%;
}
.almost-ready-field-hint {
  color: var(--text-dim);
  font-size: 0.9em;
  line-height: 1.4;
}

/* Condensed disclaimer footnote on step 1. */
.almost-ready-note {
  margin: 14px 0 0;
  color: var(--text-dim);
  font-size: 0.86em;
  line-height: 1.4;
  opacity: 0.85;
}
.almost-ready-footer {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  padding: 12px 18px 16px;
  border-top: 1px solid var(--frame-line);
}
.almost-ready-btn {
  font-family: var(--font-mono);
  font-size: 0.75em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  padding: 10px 18px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background 120ms, color 120ms, border-color 120ms;
}
.almost-ready-btn--primary {
  border: 1px solid var(--accent);
  background: var(--accent);
  color: #000;
  font-weight: 500;
}
.almost-ready-btn--primary:hover { filter: brightness(1.1); }
.almost-ready-btn--secondary {
  border: 1px solid var(--frame-line);
  background: transparent;
  color: var(--text-dim);
}
.almost-ready-btn--secondary:hover {
  color: var(--text);
  border-color: var(--text-dim);
}
/* Ghost button ("Pick another") — borderless, pushed to the far left
   of the footer via margin-right:auto. */
.almost-ready-btn--ghost {
  border: 1px solid transparent;
  background: transparent;
  color: var(--text-dim);
  margin-right: auto;
}
.almost-ready-btn--ghost:hover { color: var(--text); }
.almost-ready-btn:disabled {
  cursor: default;
  opacity: 0.6;
  filter: none;
}

/* In-modal busy / error banner shown between body and footer once Start
   is pressed. Without it the "Encoding…" status lands in the status bar
   behind the modal, so the dialog reads as frozen. */
.almost-ready-uploading {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 18px;
  padding: 10px 12px;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--accent) 7%, transparent);
  color: var(--text);
  font-size: 0.84em;
  line-height: 1.4;
}
.almost-ready-uploading.is-error {
  border-color: color-mix(in srgb, #ff5b5b 55%, var(--frame-line));
  background: color-mix(in srgb, #ff5b5b 10%, transparent);
  color: color-mix(in srgb, #ff9b9b 70%, var(--text));
}
.almost-ready-spinner {
  flex: 0 0 auto;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 2px solid color-mix(in srgb, var(--accent) 30%, transparent);
  border-top-color: var(--accent);
  animation: almost-ready-spin 0.7s linear infinite;
}
@keyframes almost-ready-spin {
  to {
    transform: rotate(360deg);
  }
}

/* ─────────────────────────────────────────────────────────────────────
   WaveformTrimDialog — interactive trim that runs between
   decodeAudioFile and the AlmostReadyDialog. Reuses the
   ``.almost-ready-backdrop / -modal / -header / -body / -footer / -btn``
   chrome so the two dialogs share their fade-in animation and the
   upload flow reads as one multi-step modal. Only the trim-specific
   bits below are new.
   ───────────────────────────────────────────────────────────────────── */
.waveform-trim-modal {
  /* Override .almost-ready-modal's 540 px width so the waveform has
   * room to read without horizontal cramping. */
  width: min(720px, 100%);
}
.waveform-trim-hint {
  margin: 0 0 12px;
  color: var(--text-dim);
  font-size: 0.92em;
  line-height: 1.45;
}
.waveform-trim-container {
  position: relative;
  width: 100%;
  height: 120px;
  margin: 18px 0 10px;
  border: 1px solid var(--accent-line);
  border-radius: 6px;
  background: rgba(0, 0, 0, 0.35);
  overflow: hidden;
  cursor: grab;
  touch-action: none; /* prevent the browser scrolling while dragging */
  user-select: none;
}
.waveform-trim-container:active { cursor: grabbing; }
.waveform-trim-canvas {
  position: absolute;
  inset: 0;
  display: block;
  pointer-events: none; /* drag binds to the container, not the canvas */
}
.waveform-trim-dim {
  position: absolute;
  top: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.55);
  pointer-events: none;
}
.waveform-trim-selection {
  position: absolute;
  top: 0;
  bottom: 0;
  background: rgba(255, 209, 102, 0.12); /* accent at 12% */
  border-left: 1px solid var(--accent);
  border-right: 1px solid var(--accent);
  box-shadow: 0 0 0 1px rgba(255, 209, 102, 0.15) inset;
  cursor: grab;
}
.waveform-trim-selection:active { cursor: grabbing; }
.waveform-trim-handle {
  /* The visible bar is 4px but the hit-box stretches to 14px so the
   * handle is still grabbable on touch / coarse pointers without
   * widening the visual chrome. */
  position: absolute;
  top: -2px;
  bottom: -2px;
  width: 14px;
  margin-left: -7px;
  cursor: ew-resize;
  touch-action: none;
}
.waveform-trim-handle::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 4px;
  transform: translateX(-50%);
  background: var(--accent);
  border-radius: 2px;
  box-shadow: 0 0 6px rgba(255, 209, 102, 0.55);
}
.waveform-trim-readout {
  display: flex;
  gap: 22px;
  font-family: var(--font-mono, ui-monospace, monospace);
  font-size: 12px;
  color: var(--text-dim);
  margin-bottom: 14px;
}
.waveform-trim-readout-label {
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-dim);
  opacity: 0.7;
  margin-right: 4px;
}
.waveform-trim-readout-selected {
  color: var(--text);
  margin-left: auto;
}
.waveform-trim-readout-selected.at-cap { color: var(--accent); }
.waveform-trim-readout-cap {
  margin-left: 4px;
  font-size: 11px;
  opacity: 0.7;
}

/* ─────────────────────────────────────────────────────────────────────
   ConfirmDialog — generic yes/no replacement for window.confirm().
   Mirrors .almost-ready-* sizing/animations but narrower (≈380px)
   since the body is just a message + two buttons. z-index 110 puts it
   in the same band as .almost-ready-backdrop; .cursor-canvas (z=150,
   root stacking context) paints over the top.
   ───────────────────────────────────────────────────────────────────── */
.confirm-dialog-backdrop {
  position: fixed;
  inset: 0;
  z-index: 110;
  background: var(--overlay-bg);
  -webkit-backdrop-filter: blur(4px);
          backdrop-filter: blur(4px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  animation: config-fade-in 180ms ease-out;
}
.confirm-dialog-modal {
  position: relative;
  width: min(380px, 100%);
  max-height: min(80vh, 480px);
  background: var(--sheet-bg);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-lg);
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  animation: config-pop-in 220ms cubic-bezier(.2, .8, .2, 1);
}
.confirm-dialog-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 18px 6px;
}
.confirm-dialog-title {
  font-family: var(--font-mono);
  font-size: 0.85em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-strong);
  font-weight: 500;
  margin: 0;
}
.confirm-dialog-body {
  flex: 1;
  overflow-y: auto;
  padding: 6px 18px 14px;
  font-family: var(--font-mono);
  font-size: 0.78em;
  color: var(--text);
}
.confirm-dialog-message {
  margin: 0;
  /* Preserve \n in plain-string messages so paragraph breaks survive
     the move from window.confirm. */
  white-space: pre-wrap;
  line-height: 1.45;
}
.confirm-dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  padding: 12px 18px 16px;
  border-top: 1px solid var(--frame-line);
}
.confirm-dialog-btn {
  font-family: var(--font-mono);
  font-size: 0.75em;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  padding: 10px 18px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background 120ms, color 120ms, border-color 120ms, filter 120ms;
}
.confirm-dialog-btn--primary {
  border: 1px solid var(--accent);
  background: var(--accent);
  color: #000;
  font-weight: 500;
}
.confirm-dialog-btn--primary:hover { filter: brightness(1.1); }
.confirm-dialog-btn--danger {
  border: 1px solid #d04848;
  background: #d04848;
  color: #000;
  font-weight: 500;
}
.confirm-dialog-btn--danger:hover { filter: brightness(1.1); }
.confirm-dialog-btn--secondary {
  border: 1px solid var(--frame-line);
  background: transparent;
  color: var(--text-dim);
}
.confirm-dialog-btn--secondary:hover {
  color: var(--text);
  border-color: var(--text-dim);
}

/* Reference picker (timbre / structure) — sits under the strength
   sliders in MainTile. Compact one-row affordance: caption + custom
   dropdown with "Input Track" and "Upload…" pinned, then the user's
   uploaded tracks. Picks up the same retro mono treatment as the
   other small in-tile bits. Multiple pickers stack with the top
   border on each separating them.

   The closed control is a <button> (not a native <select>) and the
   open menu is position: absolute. The reason: .mixer-tile is
   flex: 0 0 auto in mixer-rack-row, so its width is its children's
   max-content. A native <select>'s max-content includes the longest
   <option>, so a long filename ballooned the tile sideways. A button
   only shows the current selection (truncated), and the menu is taken
   out of flow entirely — option content cannot reach the tile's
   intrinsic-size calculation. Belt-and-suspenders: the row itself
   sets width: 0 / min-width: 100% so even the displayed selection's
   length cannot push the tile wider. The percentage min-width
   resolves to 0 during the tile's intrinsic-size pass, then to the
   tile's resolved width during layout — a standard pattern for
   "fill but don't grow the parent". */
.ref-control {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 0.55em;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-top: 4px;
  padding: 4px 6px 0;
  border-top: 1px solid var(--frame-line);
  width: 0;
  min-width: 100%;
  box-sizing: border-box;
}
.ref-control-label {
  flex: 0 0 auto;
}
.ref-control-anchor {
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  gap: 5px;
}
.ref-control-button {
  flex: 1 1 0;
  min-width: 0;
  width: 100%;
  display: flex;
  align-items: center;
  gap: 6px;
  background: transparent;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  color: var(--text);
  font-family: inherit;
  font-size: inherit;
  letter-spacing: 0.02em;
  text-transform: none;
  cursor: pointer;
  padding: 2px 22px 2px 6px;
  line-height: 1.2;
  transition: color 80ms linear, border-color 80ms linear;
  position: relative;
  text-align: left;
}
.ref-control-button-text {
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ref-control-button-caret {
  position: absolute;
  top: 50%;
  right: 7px;
  width: 10px;
  height: 5px;
  transform: translateY(-50%);
  background-image:
    linear-gradient(45deg, transparent 50%, currentColor 50%),
    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: calc(100% - 5px) 50%, 100% 50%;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  pointer-events: none;
}
.ref-control-button:hover:not(:disabled) {
  border-color: var(--text-dim);
}
.ref-control-button:focus-visible {
  outline: none;
  border-color: var(--text-dim);
}
.ref-control-button:disabled {
  opacity: 0.5;
  cursor: progress;
}
/* Sibling upload button — same outlined vocabulary as the dropdown
   button, sized to a compact icon square. (Was an unstyled default
   <button>.) */
.ref-control-upload {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  background: transparent;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  color: var(--text-dim);
  cursor: pointer;
  padding: 0;
  transition: color 80ms linear, border-color 80ms linear;
}
.ref-control-upload:hover:not(:disabled) {
  color: var(--text);
  border-color: var(--text-dim);
}
.ref-control-upload:focus-visible {
  outline: none;
  border-color: var(--text-dim);
}
.ref-control-upload:disabled {
  opacity: 0.5;
  cursor: progress;
}
.ref-control-menu {
  /* Portaled to document.body and positioned `fixed` by RefSelect.tsx
     (left / top|bottom / min-width / max-height come in as inline
     styles computed from the button's getBoundingClientRect at open
     time). The pickers live inside several nested scroll containers —
     `.mixer-rack` (overflow-y:auto) in the tabbed drawer, the small
     `.spread-section` grid cells, and the mobile sheet track — every
     one of which would clip an in-flow `position:absolute` menu. A
     body-level `fixed` element escapes all of them (no overflow or
     transform ancestor between it and the viewport), so the list is
     never cut off whichever way it opens. max-width caps growth so an
     extreme filename still fits on screen, with internal scroll for
     very long lists; max-height here is a fallback ceiling that the
     inline computed value tightens to the available side. */
  position: fixed;
  z-index: 120;
  min-width: 0;
  max-width: min(360px, 90vw);
  max-height: min(50vh, 320px);
  overflow-y: auto;
  background: var(--sheet-bg);
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-sm);
  padding: 3px;
  display: flex;
  flex-direction: column;
  gap: 1px;
  font-family: var(--font-mono);
  letter-spacing: 0.02em;
  text-transform: none;
  box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
  scrollbar-width: thin;
  scrollbar-color: var(--frame-line) transparent;
}
.ref-control-menu::-webkit-scrollbar { width: 4px; }
.ref-control-menu::-webkit-scrollbar-thumb {
  background: var(--frame-line);
  border-radius: 999px;
}
.ref-control-group {
  display: flex;
  flex-direction: column;
  gap: 1px;
  padding-top: 3px;
  margin-top: 3px;
  border-top: 1px solid var(--frame-line);
}
.ref-control-group:first-child {
  padding-top: 0;
  margin-top: 0;
  border-top: 0;
}
.ref-control-group-label {
  padding: 2px 6px 1px;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 0.85em;
  opacity: 0.7;
}
.ref-control-option {
  background: transparent;
  border: 0;
  border-radius: var(--radius-sm);
  color: var(--text);
  font: inherit;
  letter-spacing: inherit;
  text-transform: none;
  text-align: left;
  cursor: pointer;
  padding: 3px 6px;
  line-height: 1.25;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ref-control-option:hover {
  background: rgba(255, 255, 255, 0.06);
}
.ref-control-option--current {
  color: var(--accent);
}

/* ── Saved Sessions tab body ─────────────────────────────────────────
   Body of the "Saved" tab in the Full Controls panel. Header row with
   title + Save action, then a vertical list of session rows. Each row
   has an inline-editable name, age, and a small action cluster
   (Open / Rename / Delete). Matches the panel chrome vocabulary —
   transparent surfaces over the panel's recessed bay. */
.sessions-tile {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 8px 4px;
  height: 100%;
  overflow: hidden;
}
.sessions-tile-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding-bottom: 8px;
  border-bottom: 1px solid var(--frame-line);
}
.sessions-tile-title {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-strong);
}
.sessions-tile-actions {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.sessions-action-btn {
  display: inline-flex;
  align-items: center;
  padding: 5px 12px 6px;
  background: var(--accent);
  color: #fff;
  border: 1px solid transparent;
  border-radius: 999px;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  line-height: 1;
  -webkit-appearance: none;
          appearance: none;
  transition: background 140ms ease-out;
}
.sessions-action-btn:hover { background: var(--accent-hover); }
.sessions-action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.sessions-action-btn--secondary {
  background: transparent;
  border-color: var(--accent-line);
  color: var(--accent);
}
.sessions-action-btn--secondary:hover {
  background: rgba(255, 255, 255, 0.04);
  border-color: var(--accent-hover);
}
.sessions-error {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--warn, #ffcc00);
  padding: 4px 6px;
  border: 1px solid rgba(255, 204, 0, 0.4);
  border-radius: 3px;
}
.sessions-empty {
  text-align: center;
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-dim);
  padding: 24px 12px;
  line-height: 1.5;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}
.sessions-empty p { margin: 0; }
.sessions-list {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 6px;
  overflow-y: auto;
  padding-right: 4px;
  min-height: 0;
}
.sessions-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 8px 7px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  transition: background 140ms ease-out, border-color 140ms ease-out;
}
.sessions-row:hover {
  background: rgba(255, 255, 255, 0.03);
  border-color: var(--frame-line);
}
.sessions-row--current {
  border-color: var(--accent-line);
  background: rgba(255, 255, 255, 0.02);
}
.sessions-row-main {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
.sessions-row-name {
  background: none;
  border: none;
  color: var(--text-strong);
  font-family: var(--font-mono);
  font-size: 11px;
  text-align: left;
  cursor: pointer;
  padding: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
.sessions-row-name-input {
  background: rgba(0, 0, 0, 0.4);
  border: 1px solid var(--accent-line);
  border-radius: 3px;
  color: var(--text-strong);
  font-family: var(--font-mono);
  font-size: 11px;
  padding: 1px 4px;
  outline: none;
  width: 100%;
  max-width: 220px;
}
.sessions-row-age {
  font-family: var(--font-mono);
  font-size: 9px;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.sessions-row-actions {
  display: inline-flex;
  gap: 4px;
}
.sessions-row-btn {
  background: transparent;
  border: 1px solid var(--frame-line);
  border-radius: 3px;
  color: var(--text-dim);
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  padding: 4px 8px;
  -webkit-appearance: none;
          appearance: none;
  transition: color 140ms ease-out, border-color 140ms ease-out;
}
.sessions-row-btn:hover {
  color: var(--text-strong);
  border-color: var(--accent-line);
}
.sessions-row-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.sessions-row-btn--open {
  color: var(--accent);
  border-color: var(--accent-line);
}
.sessions-row-btn--open:hover {
  color: #fff;
  background: var(--accent);
  border-color: transparent;
}
.sessions-row-btn--delete:hover {
  color: var(--warn, #ffcc00);
  border-color: var(--warn, #ffcc00);
}
.install-sheet-saved-placeholder {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-dim);
  padding: 24px 12px;
  text-align: center;
}

/* ── Save pill — lower-left save indicator ───────────────────────────
   Two states. Clean: muted "Saved · 3m ago" chip. Dirty: accent-bordered
   pill with a pulsing red dot + a Save button. Click the button (or the
   whole pill in the dirty state) to fire useSavedSessions.save(). */
.save-pill {
  position: fixed;
  /* Lower-left mirror of .audio-source-dock — identical insets, so the
     save pill and the now-playing dock balance the bottom corners the
     way the wordmark and the hamburger balance the top. */
  bottom: max(
    calc(var(--hud-thickness) / 2 - 10px + var(--top-bias, 5px)),
    env(safe-area-inset-bottom, 0px)
  );
  left: max(calc(var(--hud-thickness) + 24px), env(safe-area-inset-left, 0px));
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px 5px;
  background: rgba(6, 6, 12, 0.62);
  border: 1px solid var(--frame-line);
  border-radius: 999px;
  -webkit-backdrop-filter: blur(6px);
          backdrop-filter: blur(6px);
  color: var(--text-dim);
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  line-height: 1;
  z-index: 7;
  pointer-events: auto;
  transition:
    border-color 140ms,
    color 140ms;
}
.save-pill--dirty {
  border-color: var(--accent-line);
  color: var(--text-strong);
}
/* Sign-in CTA — pops over the muted "clean" tier without the
   pulsing-red drama of the dirty tier. Accent border, accent text. */
.save-pill--signin {
  border-color: var(--accent-line);
  color: var(--accent);
  cursor: pointer;
  -webkit-appearance: none;
          appearance: none;
}
.save-pill--signin:hover {
  color: var(--accent-hover);
  border-color: var(--accent-hover);
  background: rgba(255, 255, 255, 0.04);
}
.save-pill-dot {
  display: inline-block;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: #e84f3d;
  flex-shrink: 0;
  animation: save-pill-pulse 1.4s ease-in-out infinite;
}
@keyframes save-pill-pulse {
  0%, 100% { opacity: 0.55; transform: scale(1); }
  50%      { opacity: 1;    transform: scale(1.18); }
}
@media (prefers-reduced-motion: reduce) {
  .save-pill-dot { animation: none; opacity: 1; }
}
.save-pill-label {
  white-space: nowrap;
}
.save-pill-btn {
  background: var(--accent);
  color: #fff;
  border: none;
  border-radius: 999px;
  padding: 3px 10px 4px;
  margin-left: 4px;
  cursor: pointer;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  line-height: 1;
  -webkit-appearance: none;
          appearance: none;
  transition: background 140ms ease-out;
}
.save-pill-btn:hover { background: var(--accent-hover); }
.save-pill-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 768px) {
  /* SavePill is desktop-only; mobile uses the unsaved-dot on the
     LiteControls "All controls" button instead. */
  .save-pill { display: none; }
  /* FooterLinks (VST Waitlist + Feedback) live in the HaloBadge
     hamburger menu on mobile — "Get VST" + "Bugs and feedback" entries
     are already there. No need to also surface them as pills in the
     top-right (they were crowding the topnav band on phones). */
  .footer-links { display: none; }
}
@media (max-width: 768px) {
  /* Graph lane labels (right-edge param ribbons) overflow the narrow
     mobile canvas and duplicate info already reachable in the
     drawer's CORE tab. Hide on mobile. */
  #install-video-area #graph-wrap .graph-lane-labels {
    display: none;
  }
  /* Waveform strip now ships on mobile too — see the dedicated
     positioning block earlier in this file. The previous hide-on-mobile
     rule was the workaround from when there was no touch-friendly
     scrub UI. */
}

/* Static wordmark — when the LibraryButton retired in Wave 10, the
   wordmark moved to a plain <img>. Same position as the old button so
   the brand mark still anchors the top-left. */
.library-wordmark--static {
  position: fixed;
  top: max(calc(var(--hud-thickness) / 2 - 2px + var(--top-bias, 5px)),
           env(safe-area-inset-top, 0px));
  left: max(calc(var(--hud-thickness) + 24px),
            env(safe-area-inset-left, 0px));
  z-index: 8;
  height: 28px;
  width: auto;
  display: block;
  opacity: 0.92;
  pointer-events: none;
}

/* Auto-generated knob panel (DynamicKnobPanel) — the manifest-driven
   reference surface in the drawer's "Auto" tab. Deliberately plain: it's a
   template for re-skins, not a designed tile. Slider rows reuse .knob-rack;
   these rules only cover the group headers and the enum/bool/number
   fields that have no shipped-tile equivalent. */
.dyn-knob-note {
  margin: 0 0 10px;
  font-size: 11px;
  line-height: 1.4;
  opacity: 0.6;
}
.dyn-knob-note code {
  font-size: 10px;
  opacity: 0.85;
}
.dyn-knob-empty {
  padding: 24px 8px;
  font-size: 12px;
  opacity: 0.6;
  text-align: center;
}
.dyn-knob-group {
  margin-bottom: 16px;
}
.dyn-knob-group-label {
  margin: 0 0 6px;
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.55;
}
.dyn-knob-group .knob-rack {
  flex-wrap: wrap;
  gap: 10px;
  align-items: flex-end;
}
.dyn-knob-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 11px;
}
.dyn-knob-field--bool {
  flex-direction: row;
  align-items: center;
  gap: 6px;
}
.dyn-knob-field-label {
  opacity: 0.75;
}
.dyn-knob-number,
.dyn-knob-select {
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 4px;
  color: inherit;
  font: inherit;
  font-size: 11px;
  padding: 3px 6px;
  min-width: 96px;
}
.dyn-knob-number:disabled,
.dyn-knob-select:disabled {
  opacity: 0.4;
}
.dyn-knob-unbound {
  font-size: 9px;
  font-style: normal;
  opacity: 0.4;
}

