:root {
  /* Light theme. The home + rules pages inherit these tokens for
     their backgrounds + text. The live camera screen overrides the
     body background with `#000` because dark chrome reads better
     around a video viewport. */
  --bg-0: #eef2f7;
  --bg-1: #f7f9fc;
  --bg-2: #ffffff;
  --text: #0f172a;
  --text-dim: #475569;
  --muted: #64748b;
  --kicker: #94a3b8;
  --error: #b91c1c;
}

* { box-sizing: border-box; }

/* Viewport lock — the camera fills the screen and stays full even
   when the soft keyboard opens on iOS / Android.
   - `height: 100vh` is the LARGEST viewport (keyboard hidden); on
     iOS Safari this stays put when the keyboard slides up, so the
     camera doesn't shrink and the keyboard naturally overlays the
     bottom of the camera frame. (Switching to `100dvh` would make
     the camera shrink with the keyboard — see prior discussion,
     deliberately not what we want.)
   - `position: fixed` on body locks scrolling. With everything
     inside `.live` being absolute/fixed and overlay-positioned
     against the camera, the body has nothing to scroll anyway, and
     locking it prevents iOS rubber-band + keyboard-induced layout
     shifts.
   - `overscroll-behavior: none` is belt-and-suspenders for the same
     rubber-band edge case in Chrome Android. */
html, body {
  margin: 0;
  padding: 0;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
  overscroll-behavior: none;
  /* `touch-action: none` is one of the few ways iOS Safari actually
     respects to kill pinch-zoom from CSS. Combined with the
     `maximum-scale=1, user-scalable=no` viewport meta, the page no
     longer scales — which means the camera can never end up smaller
     than the screen, so the body background can't show through. */
  touch-action: none;
}
body {
  position: fixed;
  inset: 0;
  /* Belt-and-suspenders: if anything ever leaks through (e.g. the
     camera failing to attach for a frame on Safari Reload), the
     background blends with the camera surface rather than showing
     a stark white panel. */
  background: #000;
}

/* Class-specificity rules like `.permission-screen { display: flex }`
   override the UA `[hidden] { display: none }`, so toggling the
   `hidden` attribute silently fails. Force `hidden` to win globally. */
[hidden] { display: none !important; }

/* ─── Staging harness banner ─────────────────────────────────────────
   Pinned across the very top of the viewport when the `?mode=` URL
   param picks a non-default harness, so a tester / engineer can see
   at a glance which mode this session is running against. Default-
   state pages keep the banner hidden via the `hidden` attribute, so
   production drive-bys never see it. The colour is the "experimental,
   look here" yellow; high-contrast text + bottom border keep it
   legible against the live camera background.                       */
.harness-banner {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 9999;
  padding: 6px 14px;
  background: #fde68a;
  color: #78350f;
  font-size: 12px;
  font-weight: 600;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  letter-spacing: 0.02em;
  text-align: center;
  border-bottom: 1px solid #d97706;
  /* Don't block taps on the live screen behind us — the banner is
     informational only, never interactive. */
  pointer-events: none;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
    Arial, sans-serif;
  background: radial-gradient(
    120% 80% at 50% 0%,
    var(--bg-2) 0%,
    var(--bg-1) 45%,
    var(--bg-0) 100%
  );
  color: var(--text);
  min-height: 100dvh;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* ─── Start screen ────────────────────────────────────────────────── */
.app {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  max-width: 28rem;
  width: 100%;
  margin: 0 auto;
  padding: max(2.5rem, env(safe-area-inset-top)) 1.25rem
    max(2.5rem, env(safe-area-inset-bottom));
}

.app__kicker {
  margin: 0 0 0.4rem;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--kicker);
}

.app__title {
  margin: 0 0 0.75rem;
  font-size: clamp(1.35rem, 4.5vw, 1.65rem);
  font-weight: 650;
  letter-spacing: -0.02em;
  line-height: 1.2;
}

.app__lede {
  margin: 0 0 1.75rem;
  line-height: 1.55;
  color: var(--muted);
  font-size: 0.95rem;
}

.app__center {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1.1rem;
  padding-bottom: 20%;
}

.error {
  margin-top: 1rem;
  font-size: 0.875rem;
  color: var(--error);
  text-align: center;
  max-width: 22rem;
}

/* ─── Buttons ─────────────────────────────────────────────────────── */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 0.75rem 1.35rem;
  border: none;
  border-radius: 999px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.12s ease, filter 0.12s ease, background 0.12s ease;
  font-family: inherit;
}

.btn:active { transform: scale(0.98); }

/* Recreated from aVRA's experience start button (.btn--experience). */
.btn--experience {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  min-width: 9.75rem;
  padding: 0.72rem 1.45rem;
  color: #fff;
  background: rgba(13, 17, 23, 0.78);
  border: 1px solid transparent;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.24);
}

.btn--experience::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -2;
  border-radius: inherit;
  background: conic-gradient(
    from var(--experience-angle, 0deg),
    #ec4899,
    #a855f7,
    #3b82f6,
    #22d3ee,
    #34d399,
    #ec4899
  );
  animation: experience-border-spin 1.9s linear infinite;
}

.btn--experience::after {
  content: "";
  position: absolute;
  inset: 2px;
  z-index: -1;
  border-radius: inherit;
  background: rgba(13, 17, 23, 0.92);
}

@property --experience-angle {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}

@keyframes experience-border-spin {
  to { --experience-angle: 360deg; }
}

/* Dark Share pill on the light home page. The colored voice-column
   dots inside it pop nicely against the slate background. */
.btn--permission-share {
  padding: 0.75rem 1.35rem;
  gap: 0.56rem;
  background: #0f172a;
  color: #f8fafc;
  font-size: 1rem;
  font-weight: 700;
  box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
}
.btn--permission-share:hover { filter: brightness(1.04); }
.btn--permission-share:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  transform: none;
}

/* ─── Permission gate ─────────────────────────────────────────────── */
/* The section uses `.app` layout (header at top, button stack
   centered) so the start and share screens share the same chrome.
   Only the inline error styling is unique. */
.permission-screen__error {
  margin: 0.25rem 0 0;
  font-size: 0.85rem;
  color: var(--error);
  text-align: center;
  max-width: 22rem;
}

/* ─── Animated voice-columns icon (used inside Share button) ─────── */
/* Ported from aVRA's `.guidance-voice-columns` system — four stacked
   columns of dots that bob and shimmer; here we use the small
   permission-button variant. */
.guidance-voice-columns {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 1px;
  min-width: 4.25rem;
  min-height: 4.25rem;
}

.guidance-voice-columns__col {
  display: flex;
  flex-direction: column;
  gap: 1px;
  animation: home-voice-column-rise 1.45s cubic-bezier(0.42, 0, 0.2, 1) infinite;
  will-change: transform;
}

.guidance-voice-columns__col:nth-child(2) { animation-delay: 0.13s; }
.guidance-voice-columns__col:nth-child(3) { animation-delay: 0.26s; }
.guidance-voice-columns__col:nth-child(4) { animation-delay: 0.39s; }

.guidance-voice-columns__col > span {
  position: relative;
  display: block;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background:
    radial-gradient(circle at 28% 24%, rgba(236, 254, 255, 0.94) 0%, rgba(236, 254, 255, 0.12) 30%, transparent 50%),
    radial-gradient(circle at 76% 34%, rgba(246, 169, 239, 0.82) 0%, rgba(246, 169, 239, 0.4) 34%, transparent 58%),
    radial-gradient(circle at 42% 78%, rgba(0, 196, 235, 0.92) 0%, rgba(0, 153, 213, 0.74) 44%, transparent 68%),
    linear-gradient(135deg, #d8fbff 0%, #86e8f6 28%, #05bfe4 56%, #eda3ed 100%);
  box-shadow:
    inset -0.5px -0.5px 1.4px rgba(11, 31, 46, 0.36),
    inset 0.55px 0.55px 1.25px rgba(255, 255, 255, 0.48),
    0 0 6px rgba(34, 211, 238, 0.16);
  animation: home-voice-column-shimmer 1.45s ease-in-out infinite;
}

.guidance-voice-columns__col > span::after {
  content: "";
  position: absolute;
  top: 9%;
  left: 14%;
  width: 35%;
  height: 20%;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.62);
  filter: blur(0.35px);
  transform: rotate(-20deg);
}

.guidance-voice-columns--button {
  --voice-column-rise: -5px;
  --voice-column-rebound: 1px;
  --voice-column-settle: -1px;
  gap: 0;
  align-items: center;
  min-width: 1.9rem;
  min-height: 1.9rem;
  flex: 0 0 auto;
}

.guidance-voice-columns--button .guidance-voice-columns__col { gap: 0; }

.guidance-voice-columns--button .guidance-voice-columns__col > span {
  width: 6px;
  height: 6px;
  box-shadow:
    inset -0.35px -0.35px 0.9px rgba(11, 31, 46, 0.3),
    inset 0.35px 0.35px 0.8px rgba(255, 255, 255, 0.44);
}

.guidance-voice-columns--permission {
  --voice-column-rise: -4px;
  min-width: 1.35rem;
  min-height: 1.35rem;
}

.guidance-voice-columns--permission .guidance-voice-columns__col > span {
  width: 4.5px;
  height: 4.5px;
}

@keyframes home-voice-column-rise {
  0%, 100% { transform: translateY(0); }
  22% { transform: translateY(var(--voice-column-rise, -13px)); }
  36% { transform: translateY(var(--voice-column-rebound, 2px)); }
  50% { transform: translateY(var(--voice-column-settle, -3px)); }
  64% { transform: translateY(0); }
}

@keyframes home-voice-column-shimmer {
  0%, 100% { filter: hue-rotate(0deg) saturate(1.08) brightness(1); }
  24% { filter: hue-rotate(-26deg) saturate(1.38) brightness(1.13); }
  42% { filter: hue-rotate(34deg) saturate(1.28) brightness(1.08); }
}

/* ─── Live screen ─────────────────────────────────────────────────── */
.live {
  /* Anchored to top-left + explicit 100vw / 100vh instead of
     `inset: 0`. With `inset: 0` (i.e. `bottom: 0` implied), iOS
     Safari shrinks the element vertically when the soft keyboard
     opens — that's where the black/white bands above and below
     the camera came from. With width/height in vw/vh the element
     stays at full-viewport regardless of keyboard state; the
     keyboard overlays the bottom of the camera frame. */
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background: #000;
  overflow: hidden;
}

.live__video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: #000;
}

/* Soft top + bottom vignette so the floating UI reads against any scene. */
.live__overlay {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(
      to bottom,
      rgba(0, 0, 0, 0.45) 0%,
      rgba(0, 0, 0, 0) 22%,
      rgba(0, 0, 0, 0) 78%,
      rgba(0, 0, 0, 0.55) 100%
    );
}

/* ── Pin overlay ─────────────────────────────────────────────────────
   Sits between the camera feed and the gradient/pill chrome. The SVG
   itself swallows no input (pointer-events: none on the container);
   markers don't have tap handlers in this build — they're purely a
   visual anchor that the LucasKanadeTracker drives per-frame. */
/* Spotlight overlay holds only the dim layer + mask cutout. Lives
   between the video (z<3) and the live-screen chrome (.live__top /
   .live__dock at z 4) so the dim never darkens the response pill or
   the mute FAB. The marker outline lives in #pin-overlay above
   chrome at z 5 — same lifecycle, different layer. */
.pin-spotlight-overlay {
  position: absolute;
  inset: 0;
  z-index: 3;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;
}

.pin-overlay {
  position: absolute;
  inset: 0;
  z-index: 5;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;
}

/* Proactive-watch surface lives on the main response pill: when
   the user arms a watch ("tell me when you see X"), the same pill
   morphs to `data-state="watching"` and renders the target. The
   prior `.watches-chip` top-right element was removed alongside
   this — see public/app.js > renderWatchPillMorph for the morph +
   prior-state restore logic.
   See the [data-state="watching"] rules further down in this file
   for the morphed pill's visual treatment (pulsing left-side dot,
   warm-amber tint to differentiate from regular responding/idle). */

.pin-marker {
  /* Position via CSS vars set from JS (setPinPosition) so the
     pin-enter scale() composes with translate() instead of clobbering
     the SVG `transform="..."` presentation attribute. */
  transform: translate(var(--pin-x, 0px), var(--pin-y, 0px));
  /* Gentle entrance so a fresh drop_pin() doesn't pop. */
  animation: pin-enter 0.32s cubic-bezier(0.34, 1.56, 0.64, 1) both;
  transition: opacity 0.4s ease;
}

.pin-marker--lost {
  /* Faded once tracker confidence stays below threshold. We keep the
     element in the DOM for a beat so the fade-out reads, then app.js
     removes it. */
  opacity: 0;
}

.pin-marker--ghost {
  /* Occlusion treatment — applied when LK confidence drops for ≥10
     consecutive frames (≈0.33s) but BEFORE the long fade-out kicks
     in. Pin holds its last known on-screen position and dims so
     the user can still tell where it was, without it confidently
     pretending to track a hand. Removed automatically the moment
     confidence recovers. */
  opacity: 0.3;
}
.pin-marker--ghost .pin-pulse {
  /* Stop the pulsing halo while ghosted so the dim state reads as
     a held / waiting marker, not an active one. */
  animation-play-state: paused;
  opacity: 0;
}

.pin-dot {
  fill: rgba(255, 255, 255, 0.18);
  stroke: #ffffff;
  stroke-width: 3;
  filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.55));
}

.pin-pulse {
  /* Outer pulsing halo — own circle so we can animate radius+opacity
     without affecting the inner dot's stroke. */
  fill: none;
  stroke: rgba(255, 255, 255, 0.65);
  stroke-width: 2;
  transform-origin: center;
  transform-box: fill-box;
  animation: pin-pulse 1.6s ease-in-out infinite;
}

/* Close X on each pin marker — same affordance as the watching pill's
   X, anchored on the marker itself. Built in JS (buildPinCloseButton);
   positioned per-frame for highlight kind by resizeHighlightRect
   (top-right corner of the box) and at a fixed offset for dot kind.

   Pointer-events: `all` (not `auto`). The parent #pin-overlay has
   `pointer-events: none` so video controls underneath the camera stay
   tappable through the marker. To opt back in, SVG descendants need
   to set pointer-events explicitly. Using `all` instead of `auto`
   matters: `auto` on SVG resolves to `visiblePainted`, which means
   ONLY filled / stroked pixels capture clicks. With `all`, clicks
   anywhere inside the element's bounding box fire — that closes the
   "tapped just next to the X stroke" miss path that produced the
   user report ("tap X doesn't remove the highlight").
   Combined with the larger invisible .pin-close__hit child (44×44 pt
   tap target, iOS HIG minimum), the X is now reliably tappable even
   on small highlight corners where the 12px visible disc is too
   small for a finger.

   Lost / ghost lifecycle: when the marker fades via .pin-marker--lost
   / --ghost, the close button rides the same opacity transition (it
   inherits from .pin-marker), and `pointer-events: none` would be
   added here only if we ever wanted the X tappable during ghost
   state — for now the parent's opacity ≤0.3 makes it a non-issue. */
.pin-close {
  pointer-events: all;
  cursor: pointer;
  /* No CSS transform / transition here. The position is driven by
     the SVG `transform="translate(...)"` attribute set per-frame by
     positionPinCloseButton; adding a CSS `transform: scale(...)` or
     `transition: transform` would override / fight the attribute via
     Safari's CSS-vs-attribute precedence (CSS wins), causing the X
     to briefly jump back to the marker origin during transitions —
     which on the highlight kind ends up directly over the dot/box
     and intercepts the very first touch. Keep this group's CSS
     transform-free; opacity-only feedback is fine for the tap.
     Opacity transition is safe because it doesn't touch transform. */
  transition: opacity 0.18s ease;
}
/* Invisible expanded hit target. `fill: none` (NOT alpha-0 fill)
   plus `pointer-events: all` is the safe iOS Safari recipe: alpha-0
   fills can still allocate a compositing layer that obscures lower
   siblings in some WebKit versions, while `fill: none` skips paint
   entirely. `pointer-events: all` keeps the bounding-box hit-testing
   so the 22px-radius circle still captures taps. Combined with the
   group's `pointer-events: all` above, this is the actual reason
   the X is reliably tappable on mobile. */
.pin-close__hit {
  fill: none;
  pointer-events: all;
}
.pin-close__disc {
  fill: rgba(0, 0, 0, 0.55);
  stroke: rgba(255, 255, 255, 0.85);
  stroke-width: 1.25;
  /* Visible disc itself stays clickable — belt and braces with the
     larger hit-rect above. */
  pointer-events: all;
  /* Soft outer halo so the disc reads against any background — same
     trick as the .pin-dot drop-shadow. Subtle enough not to compete
     with the marker's own pulse halo. */
  filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.5));
}
.pin-close__cross {
  fill: none;
  stroke: #ffffff;
  stroke-width: 1.6;
  stroke-linecap: round;
  /* Stroke-only paths default to `visiblePainted`, which means only
     the 1.6px stroke pixels capture clicks. Override so a tap on the
     cross fires the group handler too — without this, a tap that
     lands inside one of the four triangular gaps between the X
     strokes (within the disc, but missing the stroke) would fall
     through. */
  pointer-events: all;
}
.pin-close:hover .pin-close__disc {
  fill: rgba(0, 0, 0, 0.75);
}

/* Highlight kind — rounded rectangle drawn around a whole feature.
   Used when the agent calls drop_pin(label, kind="highlight"). The
   <rect> is centered at the group origin so the same translate()
   that moves a dot also moves the highlight; width/height are set
   by app.js from the normalized box returned by Gemini. */
.pin-box {
  fill: none;
  stroke: #ffffff;
  /* Thin stroke matches the Live-with-Gemini reference style; the
     dim spotlight below carries the visual weight, not the stroke. */
  stroke-width: 2;
  /* Soft outer glow so the rectangle reads against any background
     without the white stroke disappearing into bright spots. */
  filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.55));
  vector-effect: non-scaling-stroke;
}

/* Spotlight dim layer — full-frame semi-transparent black with a
   rounded-rect hole punched at the highlight's position (via the
   #pin-spotlight-mask SVG mask above). The hole is sized + moved
   by app.js#updateSpotlightCutout on every track frame. Off by
   default; .live--has-highlight on the .live wrapper fades it in.
   Pointer-events:none so the user's video controls underneath
   stay tappable through the dim. */
.pin-spotlight {
  fill: rgba(0, 0, 0, 0.45);
  opacity: 0;
  transition: opacity 0.32s ease;
  pointer-events: none;
}
/* .live--has-highlight is toggled on the .live wrapper (NOT on the
   spotlight SVG itself) so a single :has() rule can read the
   marker's ghost / lost classes that live in the SIBLING SVG
   #pin-overlay. Keeps the dim's lifecycle in lockstep with the
   marker's fade without each SVG needing its own class toggle. */
.live--has-highlight .pin-spotlight {
  opacity: 1;
}
.live--has-highlight:has(.pin-marker--ghost) .pin-spotlight {
  opacity: 0.3;
}
.live--has-highlight:has(.pin-marker--lost) .pin-spotlight {
  opacity: 0;
}

@keyframes pin-enter {
  0%   {
    opacity: 0;
    transform: translate(var(--pin-x, 0px), var(--pin-y, 0px)) scale(0.4);
  }
  60%  { opacity: 1; }
  100% {
    opacity: 1;
    transform: translate(var(--pin-x, 0px), var(--pin-y, 0px)) scale(1);
  }
}

@keyframes pin-pulse {
  0%, 100% { r: 18px; opacity: 0.7; }
  50%      { r: 28px; opacity: 0; }
}

.live__top {
  position: absolute;
  top: max(1rem, env(safe-area-inset-top));
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  /* Side padding folds in safe-area-inset for landscape notches
     and curved edges. Without the inset on left/right, content
     pinned to the edges (the response pill in particular) gets
     clipped behind the notch in landscape — that was the "left
     side cut off" report. The max() keeps the standalone 0.75rem
     padding intact on devices with no inset (most desktop / non-
     notched phones). */
  padding: 0 max(0.75rem, env(safe-area-inset-right)) 0
    max(0.75rem, env(safe-area-inset-left));
  pointer-events: none;
  /* Sit above the spotlight dim (z 3) so the response pill /
     loading chrome stays at full brightness when a highlight is
     up. Marker outline at z 5 still renders over the top of
     this — fine, the highlight box itself is never positioned
     where the response pill lives. */
  z-index: 4;
}

/* Hidden audio sink for the agent's TTS. Some iOS Safari versions
   render a thin native control bar even without `controls`, which
   shows up as a faint white line near the bottom. Force-hide it.
   Autoplay still works because the gesture context is preserved. */
#remote-audio { display: none !important; }

/* Belt-and-suspenders: lock the live container to viewport height
   and disable body scroll while live so nothing peeks through at
   the bottom edge under iOS Safari's overlay toolbar. */
body.live-active { overflow: hidden; }

/* Single morphing pill with three visual states.

   - data-state="preparing":  invisible. Used while the room handshake,
                               agent dispatch, and prewarms are in
                               flight so we don't show a placeholder
                               "Connecting" pill. Pill morphs in to
                               idle once the agent's audio track is
                               actually subscribed (= warm + ready).
   - data-state="idle":       plain white blob, no rainbow ring, no
                               text. Border-radius morphs organically
                               so the surface feels alive.
   - data-state="responding": expands into a wider rounded card with
                               streaming response text. Rainbow border
                               re-appears here as the "actively
                               talking" cue.

   Same DOM node throughout — only padding, opacity, scale,
   border-radius, and pseudo-element visibility animate. */
.live__pill {
  pointer-events: auto;
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  padding: 0.45rem 0.95rem;
  border-radius: 999px;
  color: #0f172a;
  font-size: 0.82rem;
  font-weight: 650;
  letter-spacing: 0.01em;
  line-height: 1;
  background: transparent;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22);
  max-width: min(100%, 28rem);
  /* Spring on transform + opacity gives the morph-in a warm pop when
     "preparing" → "idle" flips. Other transitions keep the in-state
     morph (idle ↔ responding) smooth. */
  transition:
    padding 320ms cubic-bezier(0.22, 1, 0.36, 1),
    width 320ms cubic-bezier(0.22, 1, 0.36, 1),
    height 320ms cubic-bezier(0.22, 1, 0.36, 1),
    border-radius 600ms ease,
    box-shadow 320ms ease,
    opacity 320ms ease,
    transform 520ms cubic-bezier(0.22, 1.4, 0.36, 1);
  will-change: padding, border-radius, width, height, opacity, transform;
}

/* PREPARING: chrome-less host for the cumulative 4-dot connecting
   indicator. The pill itself collapses to no background / no border
   so only the dots are visible. The dots animate cumulatively
   (1 → 2 → 3 → 4 → blank → loop) on a 2s cycle, signalling that
   the agent is still being dispatched / room handshake in flight.
   Bottom dock stays empty during this phase; mic is published-but-
   muted so the user can't be heard until they tap Live Guidance. */
/* Snappier transition curve on loading-* states only — the
   default 320ms morph (shared with responding / card /
   procedure) felt slow for the dots → pill flow. Other states
   keep the original timing untouched. */
.live__pill[data-state="loading"],
.live__pill[data-state="loading-grow"],
.live__pill[data-state="loading-label"] {
  transition:
    padding 200ms cubic-bezier(0.22, 1, 0.36, 1),
    width 200ms cubic-bezier(0.22, 1, 0.36, 1),
    height 200ms cubic-bezier(0.22, 1, 0.36, 1),
    min-width 200ms cubic-bezier(0.22, 1, 0.36, 1),
    border-radius 220ms ease,
    border-color 200ms ease,
    background-color 200ms ease,
    background 200ms ease,
    box-shadow 200ms ease,
    opacity 200ms ease;
}

.live__pill[data-state="preparing"],
.live__pill[data-state="loading"] {
  width: auto;
  height: auto;
  padding: 0.25rem 0.4rem;
  /* Pill is invisible in these states (no border, no bg) — but
     keep border-radius at 9999px throughout the loading family
     so the transition into loading-grow / loading-label never
     animates the radius (which is what produced the awkward
     "rectangle phase" earlier). The pill IS the visible circle
     in loading-grow and IS the visible stadium in loading-label;
     only width/height change between them. */
  border-radius: 9999px;
  opacity: 1;
  pointer-events: none;
  background: transparent;
  box-shadow: none;
  transform: none;
  animation: none;
}
.live__pill[data-state="preparing"]::before,
.live__pill[data-state="loading"]::before,
.live__pill[data-state="loading-grow"]::before,
.live__pill[data-state="loading-label"]::before { display: none; }
.live__pill[data-state="preparing"]::after,
.live__pill[data-state="loading"]::after,
.live__pill[data-state="loading-grow"]::after,
.live__pill[data-state="loading-label"]::after { display: none; }

/* Dots are visible only in the dotted states (preparing /
   loading). In loading-grow + loading-label the PILL itself is
   the visible shape; the dots are collapsed out of layout so
   they don't interfere with the pill's circle / stadium morph. */
.live__pill-dots {
  display: none;
  align-items: center;
  gap: 16px;
}
.live__pill[data-state="preparing"] .live__pill-dots,
.live__pill[data-state="loading"] .live__pill-dots {
  display: flex;
}

/* ── Stage 2: loading-grow ────────────────────────────────────────
   The PILL itself becomes a 28px outlined circle. Dots are
   collapsed (the .live__pill-dots host is display:none in this
   state). border-radius stays at 9999px from the loading state —
   on a square element it caps at half-height, giving a perfect
   circle. Brief transitional state (~350ms) before stage 3. */
.live__pill[data-state="loading-grow"] {
  width: 28px;
  height: 28px;
  padding: 0;
  background: transparent;
  border: 1.5px solid rgba(255, 255, 255, 0.85);
  border-radius: 9999px;
  box-shadow: none;
}

/* ── Stage 3: loading-label ──────────────────────────────────────
   Same pill, now 140px × 32px → a stadium. border-radius stays
   at 9999px throughout the morph so we never see sharp corners.
   The white outline from loading-grow is preserved (just gets a
   white background behind it now). Dots remain collapsed. */
.live__pill[data-state="loading-label"] {
  width: 140px;
  height: 32px;
  padding: 0 1rem;
  background: #fff;
  border: 1.5px solid rgba(255, 255, 255, 0.85);
  border-radius: 9999px;
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
  flex-wrap: nowrap;
  gap: 0;
  animation: none;
  justify-content: center;
}
.live__pill[data-state="loading-label"] .live__pill-response {
  display: inline-block;
  width: auto;
  max-height: 1.6rem;
  margin-top: 0;
  opacity: 1;
  transform: none;
  font-size: 0.82rem;
  font-weight: 600;
  color: rgba(15, 23, 42, 0.85);
  white-space: nowrap;
  overflow: hidden;
}
.live__pill-dot {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  /* Passcode-style indicator: all four dot OUTLINES are always
     visible (opacity 1, transparent fill, 1.5px white ring). The
     animation fills each ring with solid white in turn, then clears
     the fill back to transparent. The outlines never disappear —
     this gives the user a constant 4-slot visual anchor that
     "fills up" as the connection progresses. */
  background: transparent;
  border: 1.5px solid rgba(255, 255, 255, 0.85);
  box-sizing: border-box;
  opacity: 1;
  /* Smooth out grow / collapse / colour swap when the pill
     transitions between loading → loading-grow → loading-label.
     border-width separately so the ring vanishes cleanly when a
     dot is collapsed in the grow / label states. */
  transition:
    width 280ms cubic-bezier(0.22, 1, 0.36, 1),
    height 280ms cubic-bezier(0.22, 1, 0.36, 1),
    margin 280ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 220ms ease,
    background-color 220ms ease,
    border-color 220ms ease,
    border-width 220ms ease;
}

/* Cumulative fill: each ring fills 0.4s after the previous, all
   four hold filled for 0.4s, then all clear together for 0.4s
   before the cycle restarts. 2s total. Per-dot keyframes (vs. one
   shared keyframe with negative delays) so the clear-out is
   synchronised — staggered delays would also stagger the clear,
   breaking the "all four light up, then reset" beat.
   The same loop is shared by data-state="preparing" (initial
   connection warmup) and data-state="loading" (per-tool loading,
   stage 1 of the dots → grow → label morph). */
.live__pill[data-state="preparing"] .live__pill-dot:nth-child(1),
.live__pill[data-state="loading"] .live__pill-dot:nth-child(1) {
  animation: live-pill-dot-1 2s ease-in-out infinite;
}
.live__pill[data-state="preparing"] .live__pill-dot:nth-child(2),
.live__pill[data-state="loading"] .live__pill-dot:nth-child(2) {
  animation: live-pill-dot-2 2s ease-in-out infinite;
}
.live__pill[data-state="preparing"] .live__pill-dot:nth-child(3),
.live__pill[data-state="loading"] .live__pill-dot:nth-child(3) {
  animation: live-pill-dot-3 2s ease-in-out infinite;
}
.live__pill[data-state="preparing"] .live__pill-dot:nth-child(4),
.live__pill[data-state="loading"] .live__pill-dot:nth-child(4) {
  animation: live-pill-dot-4 2s ease-in-out infinite;
}

/* Phases (all on a 2s cycle):
   0–20%  dot 1 fills
   20–40% dot 2 fills (1 holds filled)
   40–60% dot 3 fills (1,2 hold filled)
   60–80% dot 4 fills (1,2,3 hold filled)
   80–90% all four clear together
   90–100% blank-rings pause
   We animate background-color (not opacity) so the OUTLINE stays
   visible throughout — the dot ring is always there; only the
   centre fill toggles. No glow on fill — passcode-style flat. */
@keyframes live-pill-dot-1 {
  0%   { background-color: transparent; }
  10%  { background-color: #fff; }
  80%  { background-color: #fff; }
  90%  { background-color: transparent; }
  100% { background-color: transparent; }
}
@keyframes live-pill-dot-2 {
  0%, 20% { background-color: transparent; }
  30%     { background-color: #fff; }
  80%     { background-color: #fff; }
  90%     { background-color: transparent; }
  100%    { background-color: transparent; }
}
@keyframes live-pill-dot-3 {
  0%, 40% { background-color: transparent; }
  50%     { background-color: #fff; }
  80%     { background-color: #fff; }
  90%     { background-color: transparent; }
  100%    { background-color: transparent; }
}
@keyframes live-pill-dot-4 {
  0%, 60% { background-color: transparent; }
  70%     { background-color: #fff; }
  80%     { background-color: #fff; }
  90%     { background-color: transparent; }
  100%    { background-color: transparent; }
}

/* IDLE: invisible. Once the agent is warm + listening we don't show
   anything at the top — the user has already crossed the connecting
   threshold (dots cleared) and Live Guidance is at the bottom. The
   pill returns to the foreground only when the agent actually has
   something to say (responding / card states). */
.live__pill[data-state="idle"] {
  width: 0;
  height: 0;
  padding: 0;
  opacity: 0;
  pointer-events: none;
  background: transparent;
  box-shadow: none;
  transform: scale(0.3);
}
.live__pill[data-state="idle"]::before,
.live__pill[data-state="idle"]::after { display: none; }

@keyframes live-pill-blob-morph {
  0%, 100% { border-radius: 58% 42% 48% 52% / 66% 58% 42% 34%; }
  25%      { border-radius: 44% 56% 52% 48% / 42% 38% 62% 58%; }
  50%      { border-radius: 52% 48% 48% 52% / 48% 52% 48% 52%; }
  75%      { border-radius: 38% 62% 56% 44% / 56% 42% 58% 44%; }
}

/* RESPONDING: rounded card, rainbow border re-appears (the rotating
   conic-gradient ::before) as the "actively talking" cue. */
.live__pill[data-state="responding"],
.live__pill[data-state="card"] {
  padding: 0.55rem 1rem 0.7rem 0.95rem;
  border-radius: 1.1rem;
  box-shadow: 0 14px 36px rgba(0, 0, 0, 0.32);
}

/* CARD: same shape as responding, plus tappable chips below for any
   structured tokens (case #, View record). The reply text is the
   main content — no badge, just the answer. */
.live__pill[data-state="card"] {
  padding-bottom: 0.85rem;
}

.live__pill-chips {
  display: none;
  flex-wrap: wrap;
  gap: 0.35rem;
  width: 100%;
  margin-top: 0.45rem;
}
.live__pill[data-state="card"] .live__pill-chips:not(:empty) {
  display: flex;
}

.live__pill-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  padding: 0.3rem 0.6rem;
  border-radius: 999px;
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0;
  color: #0f172a;
  background: rgba(15, 23, 42, 0.06);
  border: 1px solid rgba(15, 23, 42, 0.1);
  text-decoration: none;
  cursor: default;
  transition: background 160ms ease, transform 160ms ease;
}
a.live__pill-chip { cursor: pointer; }
a.live__pill-chip:hover,
a.live__pill-chip:active {
  background: rgba(15, 23, 42, 0.12);
  transform: translateY(-1px);
}

/* Make the response field expand in card mode the same way it does
   in responding mode — same animation, same max-height. */
.live__pill[data-state="card"] .live__pill-response {
  max-height: 16rem;
  opacity: 1;
  transform: translateY(0);
  margin-top: 0.35rem;
  overflow-y: auto;
}

.live__pill-response {
  display: block;
  width: 100%;
  margin-top: 0;
  font-size: 0.92rem;
  font-weight: 500;
  line-height: 1.35;
  letter-spacing: 0;
  color: #0f172a;
  text-align: left;
  white-space: pre-wrap;
  overflow: hidden;
  max-height: 0;
  opacity: 0;
  transform: translateY(-2px);
  transition:
    max-height 320ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 220ms ease 60ms,
    margin-top 320ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
}

.live__pill[data-state="responding"] .live__pill-response {
  max-height: 14rem;
  opacity: 1;
  transform: translateY(0);
  margin-top: 0.35rem;
  overflow-y: auto;
}

/* WATCHING: pill morphs to a slim status pill while a proactive
   watch is armed. Same surface as responding/card so the morph in/
   out is a single transition on padding + border-radius — no
   surface fade. The conic rainbow border stays disabled (no
   ::before / ::after) to keep this state visually distinct from
   "responding" (which IS the rainbow-bordered shape). A warm-amber
   pulsing dot to the left of the text is the single accent: it
   reads as "actively watching" without competing with the bottom
   pill's normal conversational rhythm.
   Same `currentColor: #0f172a` (white pill, dark text) inherits
   from .live__pill base, so the response label reads the same as
   it does in responding/card. */
.live__pill[data-state="watching"] {
  padding: 0.4rem 0.95rem 0.4rem 0.85rem;
  border-radius: 999px;
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
}

.live__pill[data-state="watching"]::before,
.live__pill[data-state="watching"]::after {
  /* Suppress the rainbow conic border so the watching pill reads
     as a calm status surface rather than an active turn. */
  display: none;
}

.live__pill[data-state="watching"] .live__pill-response {
  /* Single-line by default. Long targets ellipsize rather than
     wrap — the morph is meant to be a quiet breadcrumb, not a
     content surface. */
  display: inline-block;
  max-height: 2.2rem;
  opacity: 1;
  margin-top: 0;
  transform: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: min(64vw, 22rem);
  font-weight: 600;
  letter-spacing: 0.005em;
}

/* No leading accent dot on the watching pill — the "Watching for X"
   text alone is the morph; the prior amber pulsing dot was
   redundant with the morph itself and competed visually with the
   pulse halo when the watch detector fires (drop_pin animation).
   The keyframes are gone too so nothing else has to update if
   another state ever wants to use them. */

/* Manual clear button (X) on the watching pill. Sits to the right
   of the "Watching for X" label, only visible when the pill is in
   data-state="watching" (the `hidden` attribute is toggled from
   renderWatchPillMorph in app.js, with this CSS as belt-and-
   suspenders so the element is invisible if `hidden` ever races
   the state transition). Tap publishes `user_watch_clear` to the
   agent over LiveKit data channel — see publishWatchClearTap. */
.live__pill-watch-clear {
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  border: 0;
  padding: 0;
  margin: 0 0 0 0.45rem;
  width: 1.25rem;
  height: 1.25rem;
  display: none;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  border-radius: 999px;
  color: currentColor;
  opacity: 0.72;
  flex: 0 0 auto;
  transition: opacity 0.12s ease, transform 0.08s ease,
    background-color 0.12s ease;
}
.live__pill[data-state="watching"] .live__pill-watch-clear:not([hidden]) {
  display: inline-flex;
}
.live__pill-watch-clear:hover {
  opacity: 1;
  background: rgba(255, 255, 255, 0.16);
}
.live__pill-watch-clear:active {
  transform: scale(0.92);
}
.live__pill-watch-clear:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}
.live__pill-watch-clear svg {
  width: 0.85rem;
  height: 0.85rem;
  display: block;
}

.live__pill::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -2;
  border-radius: inherit;
  background: conic-gradient(
    from var(--experience-angle, 0deg),
    #ec4899,
    #a855f7,
    #3b82f6,
    #22d3ee,
    #34d399,
    #ec4899
  );
  animation: experience-border-spin 1.9s linear infinite;
}

.live__pill::after {
  content: "";
  position: absolute;
  inset: 1.5px;
  z-index: -1;
  border-radius: inherit;
  background: #ffffff;
}

.live__dock {
  position: absolute;
  /* Default: anchor 1.25rem above the home-indicator safe area.
     This is what desktop and non-iOS-Safari browsers use. */
  bottom: max(1.25rem, env(safe-area-inset-bottom));
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  /* Same landscape-notch handling as .live__top — fold the
     safe-area-inset into side padding so the mute FAB / dock-morph
     can't get clipped behind the notch in landscape. */
  padding: 0 max(1rem, env(safe-area-inset-right)) 0
    max(1rem, env(safe-area-inset-left));
  /* Sit above the spotlight dim (z 3) so the mute FAB stays at
     full brightness during a highlight. Tap target must remain
     reachable. */
  z-index: 4;
}

/* iOS Safari fix for the Start-button-hidden-behind-bottom-chrome
   case: `.live` is `height: 100vh` (the LARGEST viewport, used so
   the camera stays full when the keyboard opens). But Safari's
   bottom URL bar / tab bar reduces the VISIBLE area below 100vh —
   so a dock anchored to `.live`'s bottom ends up behind that chrome.
   `100svh` is the SMALLEST viewport (with all chrome expanded);
   `(100vh - 100svh)` is the height of the auto-hiding chrome.
   Adding it to `bottom` pushes the dock up by exactly that much,
   so the dock lands just above the chrome no matter what state
   it's in. When the chrome auto-hides on user scroll, the dock
   has a little extra space below — fine; better than being hidden.
   Wrapped in @supports so older browsers without svh keep the
   plain `bottom` value. */
@supports (height: 100svh) {
  .live__dock {
    bottom: calc(
      max(1.25rem, env(safe-area-inset-bottom)) + (100vh - 100svh)
    );
  }
}

/* ─── Welcome hero (top-left overlay) ──────────────────────────────
 * Visible only between "agent ready" and "user taps Start".
 * Three stacked lines: dim kicker (time-of-day greeting), italic
 * tagline, bold hero headline. Pure text — no card, no chrome —
 * with a soft drop-shadow so it stays legible over bright video
 * frames.
 *
 * Visibility is controlled by JS via the [data-show] attribute,
 * NOT the hidden attribute alone — we want a fade-in/slide-in
 * transition. JS toggles hidden=false first, then on the next
 * frame flips data-show="true" so the transition runs. On hide,
 * data-show flips back, and after the transition completes JS
 * sets hidden=true to remove from the a11y tree. */
.welcome-hero {
  position: absolute;
  top: max(1.25rem, env(safe-area-inset-top));
  left: max(1.5rem, env(safe-area-inset-left));
  right: max(1.5rem, env(safe-area-inset-right));
  max-width: 32rem;
  z-index: 4;
  pointer-events: none;
  color: #ffffff;
  opacity: 0;
  transform: translateY(-6px);
  transition:
    opacity 320ms ease,
    transform 360ms cubic-bezier(0.22, 1, 0.36, 1);
}

.welcome-hero[data-show="true"] {
  opacity: 1;
  transform: translateY(0);
}

.welcome-hero__kicker {
  margin: 0 0 0.5rem;
  font-size: 0.85rem;
  font-weight: 500;
  letter-spacing: 0.02em;
  opacity: 0.85;
}

.welcome-hero__tagline {
  margin: 0 0 0.45rem;
  font-style: italic;
  font-size: 1.15rem;
  font-weight: 500;
  line-height: 1.3;
  opacity: 0.96;
}

.welcome-hero__headline {
  margin: 0;
  /* Big hero. Scales with viewport but caps so it never crowds
     a desktop window. */
  font-size: clamp(2rem, 6vw, 3rem);
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1.05;
}

@media (max-width: 30rem) {
  .welcome-hero__headline { font-size: clamp(1.6rem, 8vw, 2.4rem); }
}

/* ─── Floating mic FAB — mute toggle ────────────────────────────────
   Default (data-muted="false"): live audio waveform animates
   continuously, telling the user the mic is hot. Tap once to mute:
   the waveform hides, the mic-with-slash icon appears, and the room's
   localParticipant gets muted in JS. Tap again to unmute. */
.dock-btn {
  border: none;
  cursor: pointer;
  font-family: inherit;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.12s ease, filter 0.12s ease, background 0.12s ease,
    color 0.12s ease;
}

.dock-btn:active { transform: scale(0.94); }

.dock-btn--mic {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  background: #fff;
  color: #0f172a;
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.32);
  animation: experience-drop-in 680ms cubic-bezier(0.2, 0.95, 0.2, 1) both;
}

/* Muted state inverts the FAB so it reads as "off". The slash on the
   icon is the primary signal; the darker fill is the supporting one. */
.dock-btn--mic[data-muted="true"] {
  background: #1f2937;
  color: #f9fafb;
}

.dock-btn__icon {
  position: relative;
  z-index: 1;
  transition: opacity 180ms ease, transform 220ms ease;
}

/* Hide the slash inside the mic SVG by default so the icon reads as
   a plain mic when (rarely) shown for a non-muted state. The SVG is
   kept hidden entirely while unmuted; we still belt-and-suspenders
   the slash so any flicker during the muted→unmuted transition is
   clean. */
.dock-btn__icon-slash {
  opacity: 0;
  transition: opacity 180ms ease;
}
.dock-btn--mic[data-muted="true"] .dock-btn__icon-slash {
  opacity: 1;
}

.dock-btn__waveform {
  display: flex;
  position: relative;
  z-index: 1;
  align-items: center;
  justify-content: center;
  gap: 3px;
  width: 32px;
  height: 24px;
}

.dock-btn__waveform > span {
  width: 3px;
  height: 10px;
  border-radius: 999px;
  background: currentColor;
  transform-origin: center;
  animation: listening-wave 1.05s ease-in-out infinite;
}

.dock-btn__waveform > span:nth-child(1) { animation-delay: -0.32s; }
.dock-btn__waveform > span:nth-child(2) { animation-delay: -0.18s; }
.dock-btn__waveform > span:nth-child(3) { animation-delay: -0.04s; }
.dock-btn__waveform > span:nth-child(4) { animation-delay: -0.18s; }
.dock-btn__waveform > span:nth-child(5) { animation-delay: -0.32s; }

/* Reactive mode: when JS taps the local mic stream and drives the
   bars from real frequency data, we kill the keyframe loop and let
   each bar's scaleY come from a per-index CSS variable. The 60ms
   transition smooths between rAF frames without making the meter
   feel laggy. */
.dock-btn__waveform.is-reactive > span {
  animation: none;
  transition: transform 60ms ease-out;
}
.dock-btn__waveform.is-reactive > span:nth-child(1) {
  transform: scaleY(var(--bar-1, 0.22));
}
.dock-btn__waveform.is-reactive > span:nth-child(2) {
  transform: scaleY(var(--bar-2, 0.22));
}
.dock-btn__waveform.is-reactive > span:nth-child(3) {
  transform: scaleY(var(--bar-3, 0.22));
}
.dock-btn__waveform.is-reactive > span:nth-child(4) {
  transform: scaleY(var(--bar-4, 0.22));
}
.dock-btn__waveform.is-reactive > span:nth-child(5) {
  transform: scaleY(var(--bar-5, 0.22));
}

/* Default (unmuted): waveform visible, icon hidden. */
.dock-btn--mic[data-muted="false"] .dock-btn__icon { display: none; }
.dock-btn--mic[data-muted="false"] .dock-btn__waveform { display: flex; }

/* Muted: icon (with slash) visible, waveform hidden. */
.dock-btn--mic[data-muted="true"] .dock-btn__icon { display: block; }
.dock-btn--mic[data-muted="true"] .dock-btn__waveform { display: none; }

/* ─── Live Guidance pill — pre-mic gate ─────────────────────────────
   Replaces the mic FAB during the "Ready" phase: room is warm, agent
   is listening, but we haven't unmuted the user yet. Tapping this
   pill is the user's explicit consent to be heard — on tap, this
   element fades out and the mic FAB fades in (see #mute-btn). The
   pill itself is purely a status + start affordance; it is never the
   mute toggle. */
.dock-pill {
  border: none;
  cursor: pointer;
  font-family: inherit;
  display: inline-flex;
  align-items: center;
  justify-content: flex-start;
  gap: 0.55rem;
  /* Stadium pill: border-radius: 999px keeps the LEFT + RIGHT ends
     as full semicircles (clamped to half-height = 22px). The
     border-radius: 50% bug from inheriting experience-drop-in is
     fixed (see dock-pill-drop-in), so we no longer need to force a
     wide aspect ratio with min-width — the content drives the
     width and the pill hugs "Live Guidance" with a small comfort
     buffer. Lands around 4:1, still clearly a stadium. */
  padding: 0 0.95rem 0 0.4rem;
  height: 44px;
  border-radius: 999px;
  background: #fff;
  color: #0f172a;
  font-size: 0.92rem;
  font-weight: 650;
  letter-spacing: 0.005em;
  line-height: 1;
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.22);
  /* Simple opacity + translate drop-in. We deliberately do NOT reuse
     experience-drop-in here: that keyframe morphs through organic
     border-radii and non-uniform scales designed for the round mic
     FAB, and its final keyframe locks border-radius to 50%. On a
     240×44 rectangle that renders as a perfect ellipse, defeating
     the stadium shape this class is supposed to produce. */
  animation: dock-pill-drop-in 420ms cubic-bezier(0.2, 0.95, 0.2, 1) both;
  transition: transform 0.12s ease, box-shadow 0.18s ease,
    opacity 0.22s ease;
}

@keyframes dock-pill-drop-in {
  0%   { opacity: 0; transform: translateY(8px); }
  100% { opacity: 1; transform: translateY(0); }
}

.dock-pill:hover {
  box-shadow: 0 14px 34px rgba(0, 0, 0, 0.28);
}

.dock-pill:active {
  transform: scale(0.97);
}

.dock-pill--guidance .dock-pill__icon {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: block;
  flex: 0 0 auto;
  /* Subtle "ready" breathing on the brand mark — slower than the
     top blob's warming pulse so they don't sync visually. */
  animation: dock-pill-icon-breathe 3.6s ease-in-out infinite;
}

.dock-pill--guidance .dock-pill__label {
  white-space: nowrap;
  padding-right: 0.05rem;
}

@keyframes dock-pill-icon-breathe {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.06); }
}

@keyframes listening-wave {
  0%, 100% {
    transform: scaleY(0.45);
    opacity: 0.55;
  }
  45% {
    transform: scaleY(2.8);
    opacity: 1;
  }
}

/* ─── Agentforce audit panel (app-wide right rail) ────────────────── */
/*
   Fixed-position, full-height right rail showing the connected
   Agentforce agent's topics + actions. Visible on every screen
   (Start / Share / Live) so the user can audit what the agent
   knows before, during, and after a session. Hidden below 720px
   because phones don't have room next to the camera, and hidden
   while live so the camera view stays full-bleed.
*/
/* Card variant. The Prompt Template + Agentforce panels were
   originally fixed left/right rails on the home page. They moved to
   /rules and now lay out as normal block-level cards inside the
   `.rules__columns` grid. Inner chrome (header, body, scrollbar) is
   unchanged so the renderers in rules.js still work verbatim. */
.asa-panel {
  display: flex;
  flex-direction: column;
  background: linear-gradient(
    180deg,
    rgba(13, 17, 23, 0.88) 0%,
    rgba(13, 17, 23, 0.96) 100%
  );
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 14px;
  backdrop-filter: blur(18px) saturate(1.2);
  -webkit-backdrop-filter: blur(18px) saturate(1.2);
  color: var(--text);
  overflow: hidden;
  box-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.45);
  /* Cap the body's scrollable region; header is sticky inside. */
  max-height: 70vh;
}

.asa-panel__header {
  padding: max(1rem, env(safe-area-inset-top)) 1.15rem 0.9rem;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}

.asa-panel__header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
}

.asa-panel__kicker {
  margin: 0;
  font-size: 0.66rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: #4ade80;
}

.asa-panel__refresh {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 1.5rem;
  padding: 0;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.04);
  color: var(--text-dim);
  cursor: pointer;
  transition: transform 0.16s ease, background 0.16s ease,
    border-color 0.16s ease, color 0.16s ease;
}
.asa-panel__refresh:hover {
  background: rgba(255, 255, 255, 0.09);
  border-color: rgba(255, 255, 255, 0.2);
  color: var(--text);
}
.asa-panel__refresh:active { transform: scale(0.92); }
.asa-panel__refresh[data-loading="true"] svg {
  animation: asa-refresh-spin 0.9s linear infinite;
}
.asa-panel__refresh[data-loading="true"] {
  pointer-events: none;
  opacity: 0.7;
}

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

.asa-panel__title {
  margin: 0.45rem 0 0.35rem;
  font-size: 1.05rem;
  font-weight: 650;
  letter-spacing: -0.01em;
  line-height: 1.2;
  color: var(--text);
}

.asa-panel__description {
  margin: 0;
  font-size: 0.78rem;
  line-height: 1.45;
  color: var(--muted);
}

.asa-panel__meta {
  margin: 0.4rem 0 0;
  font-size: 0.66rem;
  font-weight: 500;
  letter-spacing: 0.04em;
  color: rgba(255, 255, 255, 0.34);
}

.asa-panel__body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 0.5rem 0 max(0.5rem, env(safe-area-inset-bottom));
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
.asa-panel__body::-webkit-scrollbar { width: 6px; }
.asa-panel__body::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.16);
  border-radius: 999px;
}

.asa-panel__status {
  margin: 0;
  padding: 0.4rem 1.15rem;
  font-size: 0.78rem;
  color: var(--muted);
}

/* Topic group */
.asa-topic {
  padding: 0.85rem 1.15rem 0.7rem;
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.asa-topic:last-child { border-bottom: none; }

.asa-topic__label {
  margin: 0 0 0.35rem;
  font-size: 0.85rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--text);
}

.asa-topic__desc {
  margin: 0 0 0.55rem;
  font-size: 0.74rem;
  line-height: 1.45;
  color: var(--muted);
}

/* Action rows inside a topic. Two variants:
   - Plain row (no schema available, e.g. standardInvocableAction).
     Compact, name + small "platform" tag.
   - Expandable row (<details>) for flow / apex / generatePromptResponse
     where we have full input/output schemas. Click to expand, see
     fields with type + required + description. */
.asa-actions {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.asa-action {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.32rem 0.55rem;
  border-radius: 0.5rem;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.08);
  font-size: 0.74rem;
  color: var(--text-dim);
  transition: background 120ms ease, border-color 120ms ease;
}
.asa-action:hover {
  background: rgba(255, 255, 255, 0.07);
  border-color: rgba(255, 255, 255, 0.16);
}

.asa-action__dot {
  width: 0.32rem;
  height: 0.32rem;
  border-radius: 999px;
  background: #34d399;
  flex-shrink: 0;
}

.asa-action__tag {
  margin-left: auto;
  font-size: 0.6rem;
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.42);
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.08);
  padding: 0.05rem 0.4rem;
  border-radius: 999px;
}

/* Expandable variant. <details> wraps the whole row contents — the
   <summary> renders the same shape as a plain row, click to open. */
.asa-action:has(.asa-action__details) {
  padding: 0;
  background: transparent;
  border: none;
}
.asa-action__details {
  width: 100%;
  border-radius: 0.5rem;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.08);
  overflow: hidden;
}
.asa-action__details[open] {
  background: rgba(255, 255, 255, 0.06);
  border-color: rgba(255, 255, 255, 0.14);
}

.asa-action__summary {
  list-style: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.32rem 0.55rem;
  font-size: 0.74rem;
  color: var(--text-dim);
}
.asa-action__summary::-webkit-details-marker { display: none; }
.asa-action__summary::before {
  content: "";
  flex-shrink: 0;
  width: 0;
  height: 0;
  margin-right: 0.05rem;
  border: 4px solid transparent;
  border-left-color: rgba(255, 255, 255, 0.45);
  border-right: 0;
  transition: transform 160ms ease;
}
.asa-action__details[open] .asa-action__summary::before {
  transform: rotate(90deg) translateX(-1px);
}

.asa-action__label {
  font-weight: 550;
  color: rgba(255, 255, 255, 0.85);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.asa-action__meta {
  margin-left: auto;
  font-size: 0.62rem;
  letter-spacing: 0.04em;
  color: rgba(255, 255, 255, 0.42);
}

.asa-action__desc {
  margin: 0 0 0.5rem;
  padding: 0 0.65rem;
  font-size: 0.72rem;
  line-height: 1.4;
  color: rgba(255, 255, 255, 0.65);
}

.asa-schema {
  padding: 0 0.65rem 0.5rem;
}

.asa-schema__title {
  margin: 0.35rem 0 0.25rem;
  font-size: 0.6rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.5);
}

.asa-schema__list {
  display: flex;
  flex-direction: column;
  gap: 0.18rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.asa-schema__row {
  display: grid;
  grid-template-columns: minmax(7rem, max-content) auto 1fr;
  align-items: baseline;
  column-gap: 0.4rem;
  font-size: 0.7rem;
  line-height: 1.35;
}

.asa-schema__name {
  font-family: ui-monospace, SFMono-Regular, "Menlo", monospace;
  color: rgba(255, 255, 255, 0.86);
  font-weight: 600;
}
.asa-schema__type {
  font-family: ui-monospace, SFMono-Regular, "Menlo", monospace;
  color: rgba(125, 211, 252, 0.85); /* sky-300 */
  font-size: 0.65rem;
}
.asa-schema__required {
  font-size: 0.58rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: #fca5a5; /* rose */
  background: rgba(244, 63, 94, 0.12);
  padding: 0.05rem 0.3rem;
  border-radius: 999px;
  align-self: center;
  justify-self: start;
}
.asa-schema__desc {
  grid-column: 1 / -1;
  color: rgba(255, 255, 255, 0.55);
  font-size: 0.66rem;
  line-height: 1.45;
  margin-top: 0.05rem;
}

.asa-empty-actions {
  margin: 0;
  font-size: 0.7rem;
  font-style: italic;
  color: rgba(255, 255, 255, 0.32);
}

/* ─── Prompt Template panel ────────────────────────────────────────
   Card variant. Same chrome as `.asa-panel`; the kicker uses a teal
   accent so it's distinguishable from the green Agentforce kicker
   when both are stacked side by side on /rules. */
.prompt-panel {
  display: flex;
  flex-direction: column;
  background: linear-gradient(
    180deg,
    rgba(13, 17, 23, 0.88) 0%,
    rgba(13, 17, 23, 0.96) 100%
  );
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 14px;
  backdrop-filter: blur(18px) saturate(1.2);
  -webkit-backdrop-filter: blur(18px) saturate(1.2);
  color: var(--text);
  overflow: hidden;
  box-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.45);
  max-height: 70vh;
}

.prompt-panel__header {
  padding: max(1rem, env(safe-area-inset-top)) 1.15rem 0.9rem;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}

.prompt-panel__header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
}

.prompt-panel__kicker {
  margin: 0;
  font-size: 0.66rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  /* Teal — visually distinct from the right rail's green so the two
     audit surfaces read as a pair without being confusable. */
  color: #5eead4;
}

.prompt-panel__refresh {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 1.5rem;
  padding: 0;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.04);
  color: var(--text-dim);
  cursor: pointer;
  transition: transform 0.16s ease, background 0.16s ease,
    border-color 0.16s ease, color 0.16s ease;
}
.prompt-panel__refresh:hover {
  background: rgba(255, 255, 255, 0.09);
  border-color: rgba(255, 255, 255, 0.2);
  color: var(--text);
}
.prompt-panel__refresh:active { transform: scale(0.92); }
.prompt-panel__refresh[data-loading="true"] svg {
  animation: asa-refresh-spin 0.9s linear infinite;
}
.prompt-panel__refresh[data-loading="true"] {
  pointer-events: none;
  opacity: 0.7;
}

.prompt-panel__title {
  margin: 0.45rem 0 0.35rem;
  font-size: 1.05rem;
  font-weight: 650;
  letter-spacing: -0.01em;
  line-height: 1.2;
  color: var(--text);
}

.prompt-panel__description {
  margin: 0;
  font-size: 0.78rem;
  line-height: 1.45;
  color: var(--muted);
}

.prompt-panel__meta {
  margin: 0.4rem 0 0;
  font-size: 0.66rem;
  font-weight: 500;
  letter-spacing: 0.04em;
  color: rgba(255, 255, 255, 0.34);
}

.prompt-panel__body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 0.7rem 1.15rem max(0.7rem, env(safe-area-inset-bottom));
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
.prompt-panel__body::-webkit-scrollbar { width: 6px; }
.prompt-panel__body::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.16);
  border-radius: 999px;
}

.prompt-panel__status {
  margin: 0;
  font-size: 0.78rem;
  line-height: 1.5;
  color: var(--muted);
}

/* Raw template body. Whitespace preserved exactly (Prompt Builder is
   whitespace-sensitive); merge fields are highlighted via the
   `.prompt-merge-field` span inserted by the client. */
.prompt-template-body {
  margin: 0;
  padding: 0.7rem 0.8rem;
  border-radius: 0.55rem;
  background: rgba(0, 0, 0, 0.32);
  border: 1px solid rgba(255, 255, 255, 0.06);
  color: rgba(230, 237, 243, 0.92);
  font-family: ui-monospace, SFMono-Regular, "Menlo", monospace;
  font-size: 0.72rem;
  line-height: 1.55;
  white-space: pre-wrap;
  word-break: break-word;
  overflow-wrap: anywhere;
  tab-size: 2;
}

.prompt-merge-field {
  display: inline;
  padding: 0.02rem 0.28rem;
  border-radius: 0.3rem;
  background: rgba(94, 234, 212, 0.12);
  border: 1px solid rgba(94, 234, 212, 0.32);
  color: #99f6e4;
  font-weight: 600;
  white-space: nowrap;
}

@keyframes experience-drop-in {
  0% {
    opacity: 0;
    border-radius: 58% 42% 48% 52% / 66% 58% 42% 34%;
    transform: translateY(-28px) scale(0.28, 0.72);
  }
  58% {
    opacity: 1;
    border-radius: 44% 56% 52% 48% / 42% 38% 62% 58%;
    transform: translateY(4px) scale(1.08, 0.92);
  }
  78% {
    border-radius: 52% 48% 48% 52% / 48% 52% 48% 52%;
    transform: translateY(-1px) scale(0.97, 1.04);
  }
  100% {
    opacity: 1;
    border-radius: 50%;
    transform: translateY(0) scale(1, 1);
  }
}

/* ── Procedure surface (lives inside the pill) ───────────────────
 *
 * Procedure content (title, step list / step detail, progress dots)
 * is a CHILD of #live-pill, not a separate floating card. When the
 * agent returns a structured AVRAProcedure payload, JS flips
 * pill.dataset.state to one of:
 *   - procedure-preview  numbered list of all steps
 *   - procedure-active   single current step + progress dots
 *   - procedure-done     brief tick + auto-dismiss
 *
 * The pill's existing width / height / padding / border-radius
 * transitions handle the growth — the pill literally expands around
 * the procedure content the same way it expands around streaming
 * response text. One element, one identity, real morph.
 *
 * No dividers anywhere — type hierarchy carries the structure.
 * Kicker, hint, and step-meta children stay display:none so the
 * card stays minimal. */
.live__pill-procedure {
  display: none;
  flex-direction: column;
  width: 100%;
  max-height: min(60vh, 30rem);
}
.live__pill[data-state^="procedure-"] .live__pill-procedure {
  display: flex;
}

/* In any procedure state, the pill takes on card chrome (white bg,
   16px radius, card shadow, 28rem max-width, no inner padding around
   the procedure subtree). Width / padding / border-radius all
   interpolate via the .live__pill base transition. */
.live__pill[data-state^="procedure-"] {
  width: min(28rem, calc(100vw - 2rem));
  max-width: min(28rem, calc(100vw - 2rem));
  padding: 0;
  border-radius: 16px;
  /* Snap border-radius to 16px almost instantly (90ms) instead of
     riding the default 600ms curve. Without this, width/height morph
     in 320ms while the radius is still ~999px — at the mid-point the
     radius caps at half-height and the pill looks like a giant
     stadium with the procedure content inside it (frame 1 of the
     bug report). Snapping radius first means the pill becomes a
     small rounded-rectangle within ~90ms, then grows into the card
     shape — no stadium phase. */
  transition:
    padding 320ms cubic-bezier(0.22, 1, 0.36, 1),
    width 320ms cubic-bezier(0.22, 1, 0.36, 1),
    height 320ms cubic-bezier(0.22, 1, 0.36, 1),
    border-radius 90ms ease,
    box-shadow 320ms ease,
    opacity 320ms ease,
    transform 520ms cubic-bezier(0.22, 1.4, 0.36, 1);
  background: #ffffff;
  color: #14181f;
  border: 1px solid rgba(20, 24, 31, 0.08);
  box-shadow:
    0 18px 40px -16px rgba(0, 0, 0, 0.28),
    0 1px 0 rgba(255, 255, 255, 0.6) inset;
  /* Reset visibility / pointer flags overridden by idle (opacity:0,
     pointer-events:none, transform:scale(0.3)). Without these, a
     pill that was idle right before the procedure event would stay
     invisible while interpolating to procedure shape. */
  opacity: 1;
  pointer-events: auto;
  transform: none;
  text-align: left;
  align-items: stretch;
  justify-content: flex-start;
  flex-wrap: nowrap;
  /* In procedure states, suppress the rotating rainbow ring the
     pill draws via ::before in responding/card states. */
}
.live__pill[data-state^="procedure-"]::before,
.live__pill[data-state^="procedure-"]::after {
  display: none;
}

/* Hide the response + chips children whenever the pill has morphed
   into a procedure surface so they don't co-exist with the steps. */
.live__pill[data-state^="procedure-"] .live__pill-response,
.live__pill[data-state^="procedure-"] .live__pill-chips,
.live__pill[data-state^="procedure-"] .live__pill-dots {
  display: none;
}

.procedure-card__header {
  padding: 1rem 1.1rem 0.4rem;
}

.procedure-card__kicker,
.procedure-card__hint,
.procedure-card__step-meta {
  display: none;
}

.procedure-card__title {
  margin: 0;
  font-size: 1.05rem;
  font-weight: 600;
  line-height: 1.3;
  color: #14181f;
}

.procedure-card__body {
  flex: 1 1 auto;
  overflow-y: auto;
  padding: 0.4rem 1.1rem 0.6rem;
  scrollbar-width: thin;
  scrollbar-color: rgba(20, 24, 31, 0.18) transparent;
}

/* Preview mode: ordered list of all steps. No row dividers. */
.procedure-card__steps {
  list-style: none;
  counter-reset: pstep;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
}

.procedure-card__steps li {
  counter-increment: pstep;
  display: grid;
  grid-template-columns: 1.5rem 1fr;
  gap: 0.6rem;
  align-items: start;
  font-size: 0.88rem;
  line-height: 1.4;
}

.procedure-card__steps li::before {
  content: counter(pstep);
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  font-size: 0.72rem;
  letter-spacing: 0.04em;
  color: #0d8a7d;                       /* deeper teal for AA on white */
  background: rgba(13, 138, 125, 0.1);
  border-radius: 999px;
  width: 1.4rem;
  height: 1.4rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.procedure-card__step-label {
  font-weight: 500;
  color: #14181f;
}

.procedure-card__step-verification {
  /* The parent <li> is a 2-col grid (number | content). The label
     auto-places into column 2 row 1. Without grid-column: 2, the
     verification span auto-places into column 1 row 2 — a 1.5rem
     wide cell — which forces every word onto its own line. Pin
     it to the content column so it flows under the label. */
  grid-column: 2;
  display: block;
  margin-top: 0.15rem;
  font-size: 0.75rem;
  color: rgba(20, 24, 31, 0.55);
}


/* Active mode: one big current step */
.procedure-card__step-detail {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.procedure-card__step-detail .procedure-card__step-label {
  font-size: 1rem;
  font-weight: 600;
  color: #0d8a7d;
}

.procedure-card__step-detail .procedure-card__step-text {
  margin: 0;
  font-size: 0.92rem;
  line-height: 1.5;
  color: #14181f;
}

.procedure-card__step-detail .procedure-card__step-verification-active {
  margin: 0;
  padding: 0.5rem 0.7rem;
  background: rgba(13, 138, 125, 0.07);
  border-left: 2px solid rgba(13, 138, 125, 0.55);
  font-size: 0.78rem;
  color: rgba(20, 24, 31, 0.7);
  border-radius: 0 6px 6px 0;
}

/* Footer: dots only — no border, no meta text */
.procedure-card__footer {
  padding: 0.5rem 1.1rem 0.9rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
}

/* Navigation row: prev button | progress dots | next button. Sits
   on a single row inside the footer; the step-meta caption stacks
   below it. */
.procedure-card__nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  width: 100%;
}

.procedure-card__nav-btn {
  /* Bare chevron button. Tap-target is 32×32 but the visible icon
     is 16px — comfortable touch without breaking the card's tight
     vertical rhythm. */
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  border: 0;
  width: 32px;
  height: 32px;
  border-radius: 999px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  color: rgba(255, 255, 255, 0.75);
  padding: 0;
  margin: 0;
  transition: opacity 180ms ease, color 180ms ease, transform 0.08s ease,
    background-color 180ms ease;
  flex: 0 0 32px;
}

.procedure-card__nav-btn:hover:not(:disabled) {
  background: rgba(255, 255, 255, 0.10);
  color: #fff;
}

.procedure-card__nav-btn:active:not(:disabled) {
  transform: scale(0.92);
}

.procedure-card__nav-btn:disabled {
  opacity: 0.28;
  cursor: not-allowed;
}

.procedure-card__dots {
  display: flex;
  gap: 0.4rem;
  flex-wrap: nowrap;
  justify-content: center;
  flex: 0 1 auto;
}

.procedure-card__dot {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: rgba(20, 24, 31, 0.18);
  transition: background 0.2s ease, box-shadow 0.2s ease;
}

.procedure-card__dot[data-state="done"] {
  background: #0d8a7d;
}

.procedure-card__dot[data-state="current"] {
  background: #14181f;
  box-shadow: 0 0 0 3px rgba(20, 24, 31, 0.12);
}

/* Done state: tick after the title before dismiss */
.live__pill[data-state="procedure-done"] .procedure-card__title::after {
  content: " ✓";
  color: #0d8a7d;
}

/* Mobile / narrow viewport: shrink card so it doesn't crowd dock */
@media (max-width: 30rem) {
  .live__pill[data-state^="procedure-"] {
    width: calc(100vw - 1rem);
    max-width: calc(100vw - 1rem);
  }
  .live__pill-procedure {
    max-height: 50vh;
  }
  .procedure-card__title { font-size: 0.95rem; }
  .procedure-card__steps li { font-size: 0.82rem; }
}

/* ── Record surface (lives inside the pill) ─────────────────────
 *
 * Sibling of the procedure card. Reuses the in-pill morph mechanic:
 * the pill flips data-state to "record", the procedure subtree
 * stays hidden, and this subtree displays. Visual identity is
 * intentionally distinct from procedure — blue accent vs. teal —
 * so a glance is enough to tell "do these steps" from "here's
 * the record we just created / looked up".
 *
 * Layout: title (top) · detail prose (middle) · CTA + ID (footer).
 * Sticky until tap-to-dismiss — record IDs are precious. */
.live__pill-record {
  display: none;
  flex-direction: column;
  width: 100%;
}
.live__pill[data-state="record"] .live__pill-record {
  display: flex;
}

.live__pill[data-state="record"] {
  width: min(28rem, calc(100vw - 2rem));
  max-width: min(28rem, calc(100vw - 2rem));
  padding: 0;
  border-radius: 16px;
  transition:
    padding 320ms cubic-bezier(0.22, 1, 0.36, 1),
    width 320ms cubic-bezier(0.22, 1, 0.36, 1),
    height 320ms cubic-bezier(0.22, 1, 0.36, 1),
    border-radius 90ms ease,
    box-shadow 320ms ease,
    opacity 320ms ease,
    transform 520ms cubic-bezier(0.22, 1.4, 0.36, 1);
  background: #ffffff;
  color: #14181f;
  border: 1px solid rgba(20, 24, 31, 0.08);
  box-shadow:
    0 18px 40px -16px rgba(0, 0, 0, 0.28),
    0 1px 0 rgba(255, 255, 255, 0.6) inset;
  opacity: 1;
  pointer-events: auto;
  transform: none;
  text-align: left;
  align-items: stretch;
  justify-content: flex-start;
  flex-wrap: nowrap;
  cursor: pointer; /* whole card is a tap target for dismissal */
}
.live__pill[data-state="record"]::before,
.live__pill[data-state="record"]::after {
  display: none;
}
.live__pill[data-state="record"] .live__pill-response,
.live__pill[data-state="record"] .live__pill-chips,
.live__pill[data-state="record"] .live__pill-dots {
  display: none;
}

.record-card__header {
  padding: 1rem 1.1rem 0.4rem;
}

/* Kicker stays in the DOM but hidden by default — matches the
   minimalist procedure-card aesthetic. If we ever want to surface
   record type (Case / Warranty / etc.) here, flip display to block. */
.record-card__kicker {
  display: none;
  margin: 0 0 0.25rem;
  font-size: 0.68rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: #1d6bb0;
}

.record-card__title {
  margin: 0;
  font-size: 1.05rem;
  font-weight: 600;
  line-height: 1.3;
  color: #14181f;
}

.record-card__body {
  padding: 0.3rem 1.1rem 0.7rem;
  font-size: 0.92rem;
  line-height: 1.5;
  color: rgba(20, 24, 31, 0.78);
}

.record-card__footer {
  padding: 0.6rem 1.1rem 0.95rem;
  border-top: 1px solid rgba(20, 24, 31, 0.06);
}

.record-card__id {
  margin: 0;
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  font-size: 0.7rem;
  letter-spacing: 0.02em;
  color: rgba(20, 24, 31, 0.4);
}

/* Mobile / narrow viewport: shrink record card so it doesn't
   crowd the dock. Mirrors the procedure-card responsive rules. */
@media (max-width: 30rem) {
  .live__pill[data-state="record"] {
    width: calc(100vw - 1rem);
    max-width: calc(100vw - 1rem);
  }
  .record-card__title { font-size: 0.95rem; }
  .record-card__body { font-size: 0.86rem; }
}

/* ── /rules page ──────────────────────────────────────────────────
 *
 * Standalone audit page (not part of the SPA). Shows the final
 * system prompt up top, with the Prompt Template + Agentforce
 * source panels below in a responsive 2-column grid that stacks
 * on narrow viewports. The two panels reuse `.prompt-panel` /
 * `.asa-panel` chrome (now card-shaped, see above).
 */
body.rules-page {
  /* Override the .app's max-width-via-padding pattern. /rules has
     its own container width that's wider than the share screen. */
  display: block;
}

.rules {
  max-width: 64rem;
  margin: 0 auto;
  padding: max(2.25rem, env(safe-area-inset-top)) 1.25rem
    max(3rem, env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

.rules__hero {
  text-align: center;
  margin: 0 auto 0.5rem;
  max-width: 40rem;
}

.rules__kicker {
  margin: 0 0 0.4rem;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--kicker);
}

.rules__title {
  margin: 0 0 0.6rem;
  font-size: clamp(1.6rem, 4vw, 2.2rem);
  font-weight: 650;
  letter-spacing: -0.02em;
  line-height: 1.15;
}

.rules__lede {
  margin: 0;
  color: var(--muted);
  font-size: 0.92rem;
  line-height: 1.55;
}

.rules__columns {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 1.25rem;
  align-items: start;
}

@media (max-width: 760px) {
  .rules__columns {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* ── Final System Prompt card ────────────────────────────────────
 *
 * Visually heavier than the source panels below — this is the
 * payoff that says "this exact text is what Gemini receives".
 * Same visual language as the panels but with a saturated accent
 * on the kicker + a slight glow.
 */
.final-prompt {
  display: flex;
  flex-direction: column;
  background: linear-gradient(
    180deg,
    rgba(13, 17, 23, 0.92) 0%,
    rgba(13, 17, 23, 0.97) 100%
  );
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 16px;
  color: var(--text);
  overflow: hidden;
  box-shadow:
    0 24px 56px -20px rgba(0, 0, 0, 0.55),
    0 0 0 1px rgba(168, 85, 247, 0.12) inset;
}

.final-prompt__header {
  padding: 1.1rem 1.25rem 0.9rem;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}

.final-prompt__header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
}

.final-prompt__kicker {
  margin: 0;
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  /* Magenta — the "this is the real thing" moment. */
  color: #f0abfc;
}

.final-prompt__refresh {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.6rem;
  height: 1.6rem;
  padding: 0;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.04);
  color: var(--text-dim);
  cursor: pointer;
  transition: transform 0.16s ease, background 0.16s ease,
    border-color 0.16s ease, color 0.16s ease;
}
.final-prompt__refresh:hover {
  background: rgba(255, 255, 255, 0.09);
  border-color: rgba(255, 255, 255, 0.2);
  color: var(--text);
}
.final-prompt__refresh:active { transform: scale(0.92); }
.final-prompt__refresh[data-loading="true"] svg {
  animation: asa-refresh-spin 0.9s linear infinite;
}
.final-prompt__refresh[data-loading="true"] {
  pointer-events: none;
  opacity: 0.7;
}

.final-prompt__title {
  margin: 0.5rem 0 0.35rem;
  font-size: 1.18rem;
  font-weight: 650;
  letter-spacing: -0.01em;
  line-height: 1.25;
  color: var(--text);
}

.final-prompt__description {
  margin: 0;
  font-size: 0.82rem;
  line-height: 1.5;
  color: var(--muted);
}

.final-prompt__meta {
  margin: 0.4rem 0 0;
  font-size: 0.66rem;
  font-weight: 500;
  letter-spacing: 0.04em;
  color: rgba(255, 255, 255, 0.36);
  font-variant-numeric: tabular-nums;
}

.final-prompt__body {
  padding: 0.8rem 1.25rem 1.1rem;
  max-height: 60vh;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
.final-prompt__body::-webkit-scrollbar { width: 6px; }
.final-prompt__body::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.16);
  border-radius: 999px;
}

.final-prompt__status {
  margin: 0;
  padding: 0.4rem 0;
  font-size: 0.82rem;
  color: var(--muted);
}

/* The literal prompt text. Bigger / brighter than the Prompt
   Template body below so the eye lands here first. */
.final-prompt-body {
  margin: 0;
  padding: 0.85rem 1rem;
  border-radius: 0.6rem;
  background: rgba(0, 0, 0, 0.36);
  border: 1px solid rgba(255, 255, 255, 0.06);
  color: rgba(230, 237, 243, 0.96);
  font-family: ui-monospace, SFMono-Regular, "Menlo", monospace;
  font-size: 0.78rem;
  line-height: 1.6;
  white-space: pre-wrap;
  word-break: break-word;
  overflow-wrap: anywhere;
  tab-size: 2;
}

/* ─────────────────────────────────────────────────────────────────
 * Glass surfaces
 * -----------------------------------------------------------------
 * Liquid-glass treatment for the live agent surfaces. These rules
 * sit at the bottom of the file so they win cascade ties against
 * the base class rules they refine.
 *
 * Surfaces affected:
 *   - Pill: responding, card, procedure-preview / -active / -done
 *     (the "agentforce streaming loading + response + steps" surface)
 *   - Dock-pill (Live Guidance gate)
 *   - Mic FAB (.dock-btn--mic)
 *   - ASA chips (.live__pill-chip)
 *
 * Surfaces deliberately NOT touched:
 *   - Pill preparing state — kept chrome-less so the four
 *     connecting dots read as the focal point of the loading
 *     phase. They're passcode-style outlines on glass-friendly
 *     backgrounds (the live video feed) so they don't need their
 *     own surface.
 *   - Pill idle state — invisible.
 *
 * "Actively talking" cue: the rotating conic-rainbow ::before is
 * hidden here and replaced by an OUTER pulsing halo on the pill
 * while in responding / card state. The halo is the brand cue.
 * ───────────────────────────────────────────────────────────────── */

/* ── Pill surface: drop the rainbow + white-mask layers ─────────── */
.live__pill::before,
.live__pill::after {
  display: none !important;
}

/* ── Pill: loading-label (per-tool loading text) ─────────────────
   Glass surface with the SAME white outline the loading-grow
   circle uses, so the 46px outlined circle morphs cleanly into
   the same-outlined stadium pill. Once the pill settles with
   text, the border keeps reading as a continuation of that
   outline rather than a different chrome. */
.live__pill[data-state="loading-label"] {
  background: rgba(255, 255, 255, 0.12);
  -webkit-backdrop-filter: blur(28px) saturate(1.55);
  backdrop-filter: blur(28px) saturate(1.55);
  border: 1.5px solid rgba(255, 255, 255, 0.85);
  border-radius: 9999px;
  box-shadow:
    0 18px 40px -12px rgba(0, 0, 0, 0.45),
    inset 0 1px 0 rgba(255, 255, 255, 0.30),
    inset 0 -1px 0 rgba(255, 255, 255, 0.06);
}
.live__pill[data-state="loading-label"] .live__pill-response {
  color: rgba(255, 255, 255, 0.96);
}

/* ── Pill: responding + card (streaming agent answer) ───────────── */
.live__pill[data-state="responding"],
.live__pill[data-state="card"] {
  background: rgba(255, 255, 255, 0.12);
  -webkit-backdrop-filter: blur(28px) saturate(1.55);
  backdrop-filter: blur(28px) saturate(1.55);
  border: 1px solid rgba(255, 255, 255, 0.22);
  color: rgba(255, 255, 255, 0.96);
  /* Fixed radius rather than 999px:
     - Single-line replies (~48px tall) hit the radius cap and
       still read as a stadium pill.
     - Multi-line replies grow taller, the radius stays put, and
       it becomes a rounded-corner rectangle (vs. an ever-taller
       stadium with hard-rounded sides). */
  border-radius: 24px;
  box-shadow:
    0 18px 40px -12px rgba(0, 0, 0, 0.45),
    inset 0 1px 0 rgba(255, 255, 255, 0.45),
    inset 0 -1px 0 rgba(255, 255, 255, 0.06);
  /* Soft outer halo replaces the rainbow rotating border. The
     pulse is the "I'm speaking right now" cue — gentler than the
     conic-gradient rotation, more in step with a glass surface. */
  animation: glass-pill-halo 2.4s ease-in-out infinite;
}

@keyframes glass-pill-halo {
  0%, 100% {
    box-shadow:
      0 18px 40px -12px rgba(0, 0, 0, 0.45),
      0 0 0 0 rgba(255, 255, 255, 0),
      inset 0 1px 0 rgba(255, 255, 255, 0.45),
      inset 0 -1px 0 rgba(255, 255, 255, 0.06);
  }
  50% {
    box-shadow:
      0 18px 40px -12px rgba(0, 0, 0, 0.45),
      0 0 36px 6px rgba(255, 255, 255, 0.22),
      inset 0 1px 0 rgba(255, 255, 255, 0.55),
      inset 0 -1px 0 rgba(255, 255, 255, 0.06);
  }
}

/* Streaming response text + chips need to flip to white. */
.live__pill-response {
  color: rgba(255, 255, 255, 0.96);
}

/* WATCHING (iframe-mode): same glass vocabulary as responding/card
   so the morph between states is purely a padding + radius shift on
   the same surface. We keep the radius at 999px so the watching
   morph is unambiguously a slim status pill, not a card.
   No halo animation: the amber pulsing dot inside is the "active"
   cue, and a halo would compete with it. */
.live__pill[data-state="watching"] {
  background: rgba(255, 255, 255, 0.12);
  -webkit-backdrop-filter: blur(28px) saturate(1.55);
  backdrop-filter: blur(28px) saturate(1.55);
  border: 1px solid rgba(255, 255, 255, 0.22);
  color: rgba(255, 255, 255, 0.96);
  border-radius: 999px;
  box-shadow:
    0 14px 30px -10px rgba(0, 0, 0, 0.45),
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    inset 0 -1px 0 rgba(255, 255, 255, 0.06);
  /* Override the suppressed-animation rule at the base layer with
     `none` so a stale halo from responding→watching doesn't linger
     after the morph. */
  animation: none;
}

/* ── ASA chips on glass ─────────────────────────────────────────── */
.live__pill-chip {
  color: rgba(255, 255, 255, 0.95);
  background: rgba(255, 255, 255, 0.14);
  border: 1px solid rgba(255, 255, 255, 0.22);
  -webkit-backdrop-filter: blur(8px) saturate(1.2);
  backdrop-filter: blur(8px) saturate(1.2);
}
a.live__pill-chip:hover,
a.live__pill-chip:active {
  background: rgba(255, 255, 255, 0.22);
}

/* ── Pill: procedure surface (steps card) ───────────────────────── */
/* Slightly higher fill alpha than responding/card because a
   multi-line list of step text needs more contrast to stay
   legible over a busy live video feed. */
.live__pill[data-state^="procedure-"] {
  background: rgba(255, 255, 255, 0.16);
  -webkit-backdrop-filter: blur(36px) saturate(1.6);
  backdrop-filter: blur(36px) saturate(1.6);
  border: 1px solid rgba(255, 255, 255, 0.24);
  color: rgba(255, 255, 255, 0.96);
  box-shadow:
    0 22px 48px -14px rgba(0, 0, 0, 0.55),
    inset 0 1px 0 rgba(255, 255, 255, 0.5),
    inset 0 -1px 0 rgba(255, 255, 255, 0.06);
}

/* Procedure children: flip dark-on-white to white-on-glass. */
.procedure-card__title {
  color: rgba(255, 255, 255, 0.98);
}
.procedure-card__step-label {
  color: rgba(255, 255, 255, 0.94);
}
.procedure-card__step-verification {
  color: rgba(255, 255, 255, 0.7);
}
.procedure-card__steps li::before {
  /* Numbered-step counter pill: replace teal-on-pale-teal with a
     white outline ring on glass. Keeps the "this is a numbered
     step" affordance without coloured paint that fights the glass. */
  background: rgba(255, 255, 255, 0.14);
  color: rgba(255, 255, 255, 0.96);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32);
}

/* Active-step detail mode (single big current step). */
.procedure-card__step-detail .procedure-card__step-label {
  color: rgba(255, 255, 255, 0.98);
}
.procedure-card__step-detail .procedure-card__step-text {
  color: rgba(255, 255, 255, 0.92);
}

.procedure-card__step-detail .procedure-card__step-verification-active {
  background: rgba(255, 255, 255, 0.1);
  border-left-color: rgba(255, 255, 255, 0.6);
  color: rgba(255, 255, 255, 0.85);
}

/* Progress dots: pending / current / done, all on white tints. */
.procedure-card__dot {
  background: rgba(255, 255, 255, 0.28);
}
.procedure-card__dot[data-state="done"] {
  background: rgba(255, 255, 255, 0.85);
}
.procedure-card__dot[data-state="current"] {
  background: #ffffff;
  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.22);
}

/* WF mode: minimum on-screen text + numbered step progress.
   JS toggles .procedure-card--wf on #procedure-card when the active
   harness mode is wf. */
.live__pill-procedure.procedure-card--wf .procedure-card__header {
  padding: 0.85rem 1rem 0.35rem;
}
.live__pill-procedure.procedure-card--wf .procedure-card__title {
  margin: 0;
  font-size: 0.95rem;
  font-weight: 600;
  line-height: 1.3;
}
.live__pill-procedure.procedure-card--wf .procedure-card__body {
  display: none;
}
.live__pill-procedure.procedure-card--wf .procedure-card__footer {
  padding: 0.35rem 0.9rem 0.75rem;
}
.live__pill-procedure.procedure-card--wf .procedure-card__dots {
  gap: 0.28rem;
  flex-wrap: wrap;
}
.live__pill-procedure.procedure-card--wf .procedure-card__dot {
  min-width: 1.35rem;
  height: 1.35rem;
  border-radius: 999px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0 0.24rem;
  font-size: 0.66rem;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.live__pill-procedure.procedure-card--wf .procedure-card__dot[data-state="pending"] {
  background: rgba(255, 255, 255, 0.22);
  color: rgba(255, 255, 255, 0.74);
  box-shadow: none;
}
.live__pill-procedure.procedure-card--wf .procedure-card__dot[data-state="done"] {
  background: rgba(255, 255, 255, 0.82);
  color: rgba(18, 22, 28, 0.9);
  box-shadow: none;
}
.live__pill-procedure.procedure-card--wf .procedure-card__dot[data-state="current"] {
  background: #ffffff;
  color: rgba(10, 14, 20, 0.96);
  box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
}
.live__pill-procedure.procedure-card--wf .procedure-card__dot[data-viewed="true"] {
  background: rgba(255, 255, 255, 0.3);
  color: rgba(255, 255, 255, 0.98);
  box-shadow:
    inset 0 0 0 2px rgba(255, 255, 255, 0.72),
    0 0 0 1px rgba(255, 255, 255, 0.18);
}
.live__pill-procedure.procedure-card--wf .procedure-card__dot:focus-visible {
  outline: 2px solid rgba(255, 255, 255, 0.9);
  outline-offset: 1px;
}

/* "Done" tick after the title in procedure-done state. */
.live__pill[data-state="procedure-done"] .procedure-card__title::after {
  color: rgba(255, 255, 255, 0.95);
}

/* ── Pill: record surface (single record card) ──────────────────
 * Same glass treatment as procedure card so both surfaces sit
 * naturally over the live video, but uses a sky-blue accent
 * (CTA + kicker) so the user instantly tells "record" from the
 * teal/white procedure card. White-on-glass body text. */
.live__pill[data-state="record"] {
  background: rgba(255, 255, 255, 0.16);
  -webkit-backdrop-filter: blur(36px) saturate(1.6);
  backdrop-filter: blur(36px) saturate(1.6);
  border: 1px solid rgba(255, 255, 255, 0.24);
  color: rgba(255, 255, 255, 0.96);
  box-shadow:
    0 22px 48px -14px rgba(0, 0, 0, 0.55),
    inset 0 1px 0 rgba(255, 255, 255, 0.5),
    inset 0 -1px 0 rgba(255, 255, 255, 0.06);
}

.record-card__kicker {
  color: rgba(125, 195, 255, 0.95);
}
.record-card__title {
  color: rgba(255, 255, 255, 0.98);
}
.record-card__body {
  color: rgba(255, 255, 255, 0.88);
}
.record-card__footer {
  border-top-color: rgba(255, 255, 255, 0.12);
}
.record-card__id {
  color: rgba(255, 255, 255, 0.55);
}

/* ── Dock-pill: Live Guidance gate ──────────────────────────────── */
.dock-pill {
  background: rgba(255, 255, 255, 0.14);
  -webkit-backdrop-filter: blur(24px) saturate(1.5);
  backdrop-filter: blur(24px) saturate(1.5);
  border: 1px solid rgba(255, 255, 255, 0.22);
  color: rgba(255, 255, 255, 0.96);
  box-shadow:
    0 12px 28px rgba(0, 0, 0, 0.32),
    inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.dock-pill:hover {
  background: rgba(255, 255, 255, 0.18);
  box-shadow:
    0 16px 36px rgba(0, 0, 0, 0.38),
    inset 0 1px 0 rgba(255, 255, 255, 0.55);
}

/* ── Mic FAB ────────────────────────────────────────────────────── */
.dock-btn--mic {
  background: rgba(255, 255, 255, 0.14);
  -webkit-backdrop-filter: blur(24px) saturate(1.5);
  backdrop-filter: blur(24px) saturate(1.5);
  border: 1px solid rgba(255, 255, 255, 0.22);
  color: rgba(255, 255, 255, 0.96);
  box-shadow:
    0 12px 28px rgba(0, 0, 0, 0.34),
    inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
/* Muted state: slightly darker tint so the off cue still reads. */
.dock-btn--mic[data-muted="true"] {
  background: rgba(15, 23, 42, 0.45);
  border-color: rgba(255, 255, 255, 0.18);
  color: rgba(255, 255, 255, 0.92);
}

/* ─── Iframe permission gate (?source=iframe) ────────────────────────
   Full-bleed black overlay with a single Share pill, used when the
   page is embedded by the Salesforce LWC card. Sits above the
   standard light-themed permission screen so the embedded UX is
   visually self-contained (no Agentforce-branded landing inside the
   card). Stacked above .harness-banner too — in iframe mode any
   staging banner is incidental and shouldn't show through. */
.iframe-gate {
  position: fixed;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  padding: 1rem;
  background: #000;
  z-index: 1000;
}

.btn--iframe-share {
  appearance: none;
  border: 0;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.85rem 2rem;
  border-radius: 999px;
  background: #ffffff;
  color: #0f172a;
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: 0.01em;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
  transition: transform 0.15s ease, filter 0.15s ease;
}
.btn--iframe-share:hover { filter: brightness(0.96); }
.btn--iframe-share:active { transform: translateY(1px); }
.btn--iframe-share:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 3px;
}
.btn--iframe-share:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  transform: none;
}

.iframe-gate__error {
  margin: 0;
  max-width: 22rem;
  color: #f8fafc;
  font-size: 0.85rem;
  text-align: center;
}

/* ─── Pre-hydration gate selection ───────────────────────────────────
   The inline <head> script in index.html sets `.iframe-mode` on
   <html> for ?source=iframe BEFORE the body parses, so first paint
   commits the right gate. These two rules do the gate swap purely
   via CSS — author-specificity (class + id) beats the UA's
   [hidden] { display: none } rule, so the section in the HTML can
   keep its `hidden` attribute as the standalone default while
   iframe-mode wins by selector specificity.
   /app.js's IFRAME_MODE block also flips the `hidden` attributes,
   but that runs after the script downloads + evaluates and is now
   purely belt-and-suspenders behind these rules. */
.iframe-mode #permission-screen { display: none; }
.iframe-mode #iframe-gate {
  display: flex;
}

/* ─── iframe-mode placement scaling ──────────────────────────────────
   In iframe mode (`.iframe-mode`, set when ?source=iframe — first
   on <html> by the inline script in index.html, then mirrored to
   <body> by app.js init), the chrome offsets scale with iframe
   height instead of sitting at a fixed rem offset tuned for a full
   phone viewport. The top response pill and bottom dock end up
   proportionally close to the visible edges of a card-sized
   container, leaving more vertical room for the camera frame.
   `clamp(min, 2-2.5vh, current-standalone-rem)` caps at today's
   standalone offsets so the chrome can only move CLOSER to the
   edges in short iframes — never farther. Element sizes (Start pill
   height, mic FAB diameter, fonts) are deliberately untouched so
   the embedded session still looks visually consistent with the
   standalone session, just packed against the edges. */
.iframe-mode .live__top {
  top: clamp(0.35rem, 2vh, 1rem);
}
.iframe-mode .live__dock {
  bottom: clamp(0.5rem, 2.5vh, 1.25rem);
}

/* iframe-mode override above wins specificity over the global
   `.live__dock` @supports rule earlier in this file — without
   repeating the svh offset here, the dock falls back to a small
   clamped bottom that lives INSIDE `.live` (which is `height:
   100vh`, i.e. the LARGEST viewport). On iOS Safari that puts the
   dock behind the bottom URL bar / tab bar — exactly what the user
   reported (Start button hidden on first load).
   Same trick as the global rule: shift the dock up by
   `(100vh - 100svh)`, which is the height of Safari's auto-hiding
   chrome. Wrapped in `@supports` so older browsers keep the small
   clamped bottom (which works fine on browsers that don't have the
   chrome-overlap quirk in the first place). */
@supports (height: 100svh) {
  .iframe-mode .live__dock {
    bottom: calc(clamp(0.5rem, 2.5vh, 1.25rem) + (100vh - 100svh));
  }
  .iframe-mode .live__top {
    /* Mirror for the top: iOS Safari URL bar (when configured at
       top) takes a slice of the viewport that the `top: clamp(...)`
       offset alone doesn't account for. `100lvh - 100svh` is the
       same `chrome` delta but we don't actually need to apply it
       here because `env(safe-area-inset-top)` already covers the
       notch + URL-bar overlap on iOS in the top placement — leaving
       this empty for symmetry / future tuning. */
  }
}

/* ─── Iframe-mode mic FAB sizing + user-speaking pill ──────────────
   Standalone keeps the 64×64 mic FAB (comfortable mobile tap
   target). In iframe mode the embed is height-constrained and the
   FAB visually dwarfs the 44-tall Start pill — we shrink it to
   44×44 so the two dock states (Start pill ↔ mic FAB) read as the
   same size. The icon SVG inside is hardcoded at 26px in the HTML
   width/height attrs, which would look heavy in a 44 container,
   so we override via CSS.

   The user-speaking expansion is a new state on top of mute/unmute.
   When the local participant is actively talking, JS adds the
   .dock-btn--speaking class to the FAB; max-width grows from 44 to
   ~280 and the .dock-btn__user-transcript span fades in to fill
   the right side with white text on the same translucent dark
   background. With the dock's existing justify-content: center the
   pill stays horizontally centered as it grows.

   Single line, no wrapping. JS scrolls the span to its right edge
   on each text update so the newest words are always visible
   (leading text overflows out the left, hidden by overflow:hidden).
   Pill collapses 700ms after the segment commits (is_final). */

.iframe-mode .dock-btn--mic {
  /* CRITICAL: width MUST be auto (not fixed) so the .dock-btn--speaking
     max-width can actually grow the element. A hard `width: 44px`
     would clamp the FAB at 44 regardless of max-width — the cap can
     only relax a fixed width, not raise it. min-width + max-width
     give us "44 when no content drives it bigger; up to N when
     content does". The transition fires on max-width changing
     between collapsed and expanded states. */
  width: auto;
  min-width: 44px;
  max-width: 44px;
  height: 44px;
  border-radius: 999px;
  padding: 0;
  overflow: hidden;
  /* CRITICAL: override the inherited `animation: experience-drop-in`
     from .dock-btn--mic. That keyframe morphs through organic
     border-radii AND locks `border-radius: 50%` as its final
     value (see line ~1701). With `animation-fill-mode: both`, that
     50% persists forever after the animation finishes — and on a
     non-square element it renders as a perfect ellipse, not a
     stadium. Same trap that the comment on .dock-pill flagged.
     Swap to dock-pill-drop-in which only animates opacity +
     translateY, leaving border-radius alone. */
  animation: dock-pill-drop-in 420ms cubic-bezier(0.2, 0.95, 0.2, 1) both;
  transition:
    max-width 220ms cubic-bezier(0.2, 0.95, 0.2, 1),
    background-color 180ms ease,
    transform 0.12s ease,
    box-shadow 0.18s ease;
}

.iframe-mode .dock-btn--mic .dock-btn__icon {
  width: 22px;
  height: 22px;
  flex: 0 0 22px;
}

.dock-btn__user-transcript {
  /* Hidden in standalone and in iframe-mode collapsed state. The
     iframe-mode rule below promotes display:flex once we enter the
     speaking state. */
  display: none;
}

.iframe-mode .dock-btn--mic.dock-btn--speaking {
  /* Expanded container. Two visual states without any class
     switching:
       - 1 line of text  → height 44, border-radius 22 = stadium pill
       - 2 lines of text → height ~64, border-radius 22 < height/2 =
                            rounded rectangle
     The same 22px radius produces both shapes because a pill is
     just a rounded rect where the radius equals half the height.

     Layout: avatar circle on the left, then text. align-items
     center keeps the avatar vertically centered no matter how
     many lines the text takes. height: auto + min-height: 44 lets
     the box grow as the text wraps. Padding is symmetric vertical
     to give a 2-line box clean breathing room. */
  max-width: 320px;
  min-height: 44px;
  height: auto;
  border-radius: 22px;
  background: rgba(15, 23, 42, 0.6);
  border-color: rgba(255, 255, 255, 0.2);
  justify-content: flex-start;
  align-items: center;
  padding: 6px 14px 6px 6px;
  gap: 8px;
}

/* Waveform becomes its own circular "avatar" inside the expanded
   container. The base .dock-btn__waveform is a 32×24 inline flex
   of 5 bars; we wrap it in a 32×32 circle with a darker background
   so it reads as a distinct surface and the transcript text can
   never collide with it. flex: 0 0 32px locks the avatar width
   during expansion (no shrinking under text pressure). */
.iframe-mode .dock-btn--mic.dock-btn--speaking .dock-btn__waveform {
  width: 32px;
  height: 32px;
  flex: 0 0 32px;
  background: rgba(0, 0, 0, 0.42);
  border-radius: 50%;
}

.iframe-mode .dock-btn--mic.dock-btn--speaking .dock-btn__user-transcript {
  /* Left-anchored text. Wraps onto a second line if the first
     overflows; clamped to 2 lines max with an ellipsis at the
     tail (rarely hit — most utterances fit in ≤2 lines at this
     width). Critically: NOT scrolled and NOT masked at the leading
     edge — the previous build was scrolled to the right edge,
     which left a faded mid-word fragment ("s the test...") at the
     visible start. Anchoring at the left + wrapping fixes both. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  flex: 0 1 auto;
  min-width: 0;
  opacity: 1;
  color: #fff;
  font-size: 0.85rem;
  font-weight: 500;
  line-height: 1.25;
  letter-spacing: 0.01em;
  overflow: hidden;
  text-overflow: ellipsis;
  word-break: normal;
  overflow-wrap: anywhere;
  padding: 0;
}

/* ─── Dock morph + internal-mode keyboard input ─────────────────────
   The dock pill now lives inside a wrapper (#dock-morph) that is a
   no-op in standalone / iframe-no-internal builds — it's just a
   transparent flex container holding the existing #mute-btn — but
   morphs into a liquid-glass segmented pill in iframe + internal
   mode, with a [mic | keyboard] toggle on the left and a text-input
   area on the right that swaps in for #mute-btn when the user
   flips to keyboard mode.

   Layering rules:
     - .dock-morph: layout-only by default; gains paint + glass in
       iframe+internal mode.
     - .dock-morph__toggle / __text are CSS-hidden in non-internal
       mode regardless of any [hidden] attribute fighting, so a
       stale build can never leak the toggle into standalone.
     - #mute-btn keeps its own background + border in non-internal
       mode (current behaviour); we strip them inside the morph
       pill so the parent's glass takes over visually. */

.dock-morph {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0;
}

/* HTML [hidden] needs to beat the .dock-morph { display: flex } rule
   above. Attribute selector + class is more specific than the bare
   class so this wins without !important. Without it the wrapper
   paints its glass pill before the user taps Start (visible as an
   empty toggle pill next to the Live Guidance button). */
.dock-morph[hidden] {
  display: none;
}

/* Non-internal builds: the dock-morph element is invisible — it's
   just a layout passthrough around #mute-btn. The toggle and text
   subtrees are unreachable visually and tap-wise. */
.dock-morph__toggle,
.dock-morph__text {
  display: none;
}

/* ── iframe + internal mode ───────────────────────────────────── */

.iframe-mode.internal-mode .dock-morph {
  /* Layout container — sized to fit toggle + active content. The
     wrapper itself is transparent in voice mode (toggle's own inset
     capsule is the visible chrome and transcript floats clean on
     the video) and gains the liquid-glass shell only in text mode,
     where the typed input row needs a unified surface. See the
     [data-input-mode="text"] override below.
     border-radius 999px keeps the pill as a stadium at every size
     — wraps tight around just the toggle in idle and stays a
     stadium when it grows wider for typed input or a multi-line
     transcript. Padding stays small (3px) so the toggle doesn't
     get lost in vertical whitespace. */
  position: relative;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 999px;
  padding: 3px;
  gap: 4px;
  /* Min/max width: collapses tight around just the toggle in idle,
     grows to fit the typed input in text mode and a multi-line
     transcript in voice-speaking mode. */
  min-width: 0;
  max-width: min(420px, calc(100vw - 24px));
  /* Override parent dock's potential opacity-on-disable styling. */
  opacity: 1;
}

/* Text mode gets a light liquid-glass shell — white tint + heavy
   blur instead of the previous dark navy. Reads as Apple-style
   frosted glass over whatever camera frame is behind it. */
.iframe-mode.internal-mode .dock-morph[data-input-mode="text"] {
  background: rgba(255, 255, 255, 0.14);
  border-color: rgba(255, 255, 255, 0.22);
  backdrop-filter: blur(28px) saturate(180%);
  -webkit-backdrop-filter: blur(28px) saturate(180%);
  box-shadow:
    0 8px 24px rgba(0, 0, 0, 0.20),
    inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}

/* Toggle = segmented [🎙️ | ⌨️] pill. Always visible in internal mode,
   regardless of which content side is active. */
.iframe-mode.internal-mode .dock-morph__toggle {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  /* The toggle has its own inset darker capsule so it reads as a
     distinct surface from the rest of the pill. Bumped from 0.32 →
     0.48 alpha: against the warm-toned voice pill (rgba(255,255,
     255,0.14) + blur), the prior 0.32 inset wasn't dark enough to
     produce contrast for the white icons/waveform sitting on top —
     they were reading as low-luminance against an already-bright
     surround. 0.48 darkens the local surface enough that the white
     icon + waveform pop against it without needing a body fill on
     the segments. */
  background: rgba(0, 0, 0, 0.48);
  border-radius: 999px;
  padding: 2px;
  flex: 0 0 auto;
}

.iframe-mode.internal-mode .dock-morph__toggle-btn {
  /* Bare button reset. Each segment is a 32×32 hit target with the
     icon centered. Inactive segments use a brighter dim (#fff @
     0.78) than the prior 0.55 — at 0.55 the keyboard icon was
     reading as dark grey against the warm voice pill in iframe
     internal mode. 0.78 keeps the active/inactive contrast intact
     (active is 0.95 white pill or 0.95 transparent voice override)
     while pulling the inactive icon well clear of "looks black". */
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  border: 0;
  width: 32px;
  height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 999px;
  color: rgba(255, 255, 255, 0.78);
  cursor: pointer;
  padding: 0;
  margin: 0;
  transition: background-color 180ms ease, color 180ms ease,
    transform 0.08s ease;
}

.iframe-mode.internal-mode .dock-morph__toggle-btn[aria-selected="true"] {
  /* "Active" pill background. Same off-white that the voice-mode
     waveform uses, so the active mode reads as "lit". */
  background: rgba(255, 255, 255, 0.95);
  color: #0f172a;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
}

/* Voice toggle never gets the white pill: the live waveform IS the
   active indicator (animation > highlight), and the slash icon
   already reads as muted. Override the generic [aria-selected]
   active rule above for this segment only. Keeps the toggle reading
   as one continuous capsule. */
.iframe-mode.internal-mode .dock-morph__toggle-btn--voice[aria-selected="true"] {
  background: transparent;
  color: rgba(255, 255, 255, 0.95);
  box-shadow: none;
}

/* Clip the waveform animation to the 32×32 button circle. Bars
   scale up to ~2.8x on transients; without `overflow: hidden` they
   visibly burst past the round button edge (and past the parent
   toggle capsule's border-radius). */
.iframe-mode.internal-mode .dock-morph__toggle-btn--voice {
  overflow: hidden;
}

/* Voice toggle button now contains the waveform + slash icon (same
   structure as #mute-btn). The .dock-btn--mic[data-muted=…] cascade
   doesn't apply here because we deliberately avoided adding the
   .dock-btn--mic class — these are an inset 32×32 toggle, not a
   standalone FAB. So we re-create the muted/unmuted visual swap
   scoped to the toggle's own class. */
.iframe-mode.internal-mode .dock-morph__toggle-btn--voice .dock-btn__waveform {
  display: flex;
  width: 28px;
  height: 20px;
  /* Re-center inside the 32x32 button — width:28 leaves 2px on each
     side so the leftmost/rightmost bars don't kiss the button edge. */
}

.iframe-mode.internal-mode .dock-morph__toggle-btn--voice .dock-btn__icon {
  display: none;
  width: 18px;
  height: 18px;
}

.iframe-mode.internal-mode
  .dock-morph__toggle-btn--voice[data-muted="true"]
  .dock-btn__waveform {
  display: none;
}

.iframe-mode.internal-mode
  .dock-morph__toggle-btn--voice[data-muted="true"]
  .dock-btn__icon {
  display: block;
}

/* Pin both the unmuted (waveform bars use `background: currentColor`)
   and the muted (slash SVG uses `stroke="currentColor"`) states to
   full-white on the voice toggle.
   The generic `.dock-morph__toggle-btn[aria-selected="true"]` cascade
   sets color: #0f172a (the dark text color for the active white pill
   on the keyboard side), and the voice-specific override at line ~3014
   only catches `aria-selected="true"` — that left a window during the
   initial mute (before the user has explicitly toggled into voice
   tab) where the toggle button still resolves to the dark color
   from the generic active rule, which is what produced the
   "icon and audio waves look black on first mute" report.
   Forcing currentColor here on the voice segment in BOTH muted and
   unmuted variants closes that gap and keeps the icon + bars
   readable against the dark inset capsule background. */
.iframe-mode.internal-mode .dock-morph__toggle-btn--voice,
.iframe-mode.internal-mode .dock-morph__toggle-btn--voice[data-muted="false"],
.iframe-mode.internal-mode .dock-morph__toggle-btn--voice[data-muted="true"] {
  color: rgba(255, 255, 255, 0.95);
}

/* The slash inside the mic SVG is `opacity: 0` by default and only
   set to `opacity: 1` when its parent matches `.dock-btn--mic
   [data-muted="true"]`. The voice toggle isn't `.dock-btn--mic`, so
   without this rule the muted-state SVG renders as a plain non-
   slashed mic icon — visually almost identical to the unmuted mic.
   This rule mirrors the existing one (line 1119) but scoped to the
   toggle button so the slash actually appears when muted. */
.iframe-mode.internal-mode
  .dock-morph__toggle-btn--voice[data-muted="true"]
  .dock-btn__icon-slash {
  opacity: 1;
}

/* Mute-btn keeps its own waveform + slash icon (the standalone build
   needs them). In internal mode the toggle button takes over, so we
   hide the mute-btn's copies — the mute-btn DOM stays only as a
   container for the right-side transcript expansion. */
.iframe-mode.internal-mode .dock-morph #mute-btn .dock-btn__waveform,
.iframe-mode.internal-mode .dock-morph #mute-btn .dock-btn__icon {
  display: none;
}

.iframe-mode.internal-mode .dock-morph__toggle-btn:hover {
  color: rgba(255, 255, 255, 0.85);
}

/* Dark-text-on-active-white-pill ONLY applies to the keyboard
   segment. The voice segment is also `aria-selected="true"` when
   voice mode is active, but it uses a transparent background and
   stays white (see the .dock-morph__toggle-btn--voice[aria-selected]
   rule above). Without the :not() exclusion, tapping the voice
   segment to (un)mute would briefly inherit #0f172a via iOS Safari's
   sticky :hover state on tap — producing a flash of black-on-warm-
   pill for ~200ms until hover clears. That was the "mute icon goes
   black when I tap" report. Excluding the voice segment here keeps
   the icon and waveform white through the tap. */
.iframe-mode.internal-mode
  .dock-morph__toggle-btn[aria-selected="true"]:not(.dock-morph__toggle-btn--voice):hover {
  color: #0f172a;
}

.iframe-mode.internal-mode .dock-morph__toggle-btn:active {
  transform: scale(0.94);
}

/* ── Voice content inside the morph pill ────────────────────────
   #mute-btn keeps all its existing behaviour (mute/unmute on tap,
   waveform animation, transcript expansion), but loses ALL its
   surface chrome (background + border + box-shadow + backdrop-
   filter) so the parent .dock-morph glass is the only visible
   pill. The base .dock-btn--mic { backdrop-filter: blur(24px) }
   was producing the secondary darker pill behind the transcript
   text — killing it here lets the dock-morph wrapper be the one
   continuous capsule that contains both the toggle and the
   transcript. */
.iframe-mode.internal-mode .dock-morph #mute-btn {
  background: transparent !important;
  border-color: transparent !important;
  box-shadow: none !important;
  backdrop-filter: none !important;
  -webkit-backdrop-filter: none !important;
  /* Drop the entrance animation on the avatar — the morph wrapper
     handles the global dock entrance via dock-pill-drop-in. */
  animation: none;
}

/* Voice mode paints the liquid-glass surface on the dock-morph
   wrapper ALWAYS — in idle it's just wrapping the toggle, and
   then the same pill morphs / grows wider as the transcript
   expansion pushes #mute-btn's max-width from 44 → 320. One
   continuous capsule the entire time; no pop-in when the user
   starts talking. The transition on background + max-width
   smooths the surface fade-in on mode switch and the size morph
   when the transcript lands. */
.iframe-mode.internal-mode .dock-morph[data-input-mode="voice"] {
  background: rgba(255, 255, 255, 0.14);
  border-color: rgba(255, 255, 255, 0.22);
  backdrop-filter: blur(28px) saturate(180%);
  -webkit-backdrop-filter: blur(28px) saturate(180%);
  box-shadow:
    0 8px 24px rgba(0, 0, 0, 0.20),
    inset 0 0 0 1px rgba(255, 255, 255, 0.06);
  transition:
    background 220ms ease,
    border-color 220ms ease,
    box-shadow 220ms ease;
}

/* In voice mode the text-area shouldn't even claim a flex slot, so
   the toggle sits flush against the mute-btn avatar. */
.iframe-mode.internal-mode .dock-morph[data-input-mode="voice"]
  .dock-morph__text {
  display: none;
}

/* In idle voice mode (mute-btn doesn't have .dock-btn--speaking),
   the mute-btn is invisible — the toggle is the only visible
   chrome and there should be no empty slot to the right of it.
   Override the base `.iframe-mode .dock-btn--mic { min-width: 44px;
   max-width: 44px; height: 44px }` to collapse the bounding box
   to zero, so the dock-morph pill wraps tight around just the
   toggle. When the user starts speaking, the .dock-btn--speaking
   class lands and the speaking-state rule restores the expanded
   dimensions (min-height 44 + max-width 320), with the existing
   max-width transition smoothing the growth. */
.iframe-mode.internal-mode .dock-morph[data-input-mode="voice"]
  #mute-btn:not(.dock-btn--speaking) {
  min-width: 0;
  max-width: 0;
  min-height: 0;
  height: 0;
  margin: 0;
  padding: 0;
  border: 0;
  overflow: hidden;
}

/* In text mode, the mic FAB is replaced by the text input row. */
.iframe-mode.internal-mode .dock-morph[data-input-mode="text"] #mute-btn {
  display: none !important;
}

/* ── Text content (keyboard mode) ──────────────────────────────── */

.iframe-mode.internal-mode .dock-morph[data-input-mode="text"]
  .dock-morph__text {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  /* The text area expands to fill whatever space the morph pill's
     max-width leaves after the toggle. The input itself sets its
     own min-width:0 so flex shrink works. */
  flex: 1 1 auto;
  min-width: 0;
  padding: 0 4px 0 8px;
}

.iframe-mode.internal-mode .dock-morph__text-input {
  /* Bare text input on a transparent surface — the parent pill is
     the visible chrome. No outline; focus state lives on the
     parent (caret blink is enough signal). */
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  border: 0;
  outline: 0;
  color: #fff;
  font: inherit;
  font-size: 0.95rem;
  line-height: 1.2;
  flex: 1 1 auto;
  min-width: 0;
  /* Single line. Submitting clears the value (see app.js); long
     pending content scrolls horizontally inside the input. */
  width: 100%;
  height: 36px;
  padding: 0;
}

.iframe-mode.internal-mode .dock-morph__text-input::placeholder {
  /* No placeholder by design, but keep the rule in case a future
     build wants to add domain-specific copy. */
  color: rgba(255, 255, 255, 0.42);
}

.iframe-mode.internal-mode .dock-morph__text-input::selection {
  background: rgba(255, 255, 255, 0.25);
}

.iframe-mode.internal-mode .dock-morph__text-send {
  /* Bare arrow icon — no circle, no background. The icon itself is
     the affordance; the pill's glass surface frames the whole row. */
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  border: 0;
  color: rgba(255, 255, 255, 0.95);
  width: 32px;
  height: 32px;
  border-radius: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex: 0 0 32px;
  padding: 0;
  margin: 0;
  transition: opacity 180ms ease, transform 0.08s ease;
  box-shadow: none;
}

.iframe-mode.internal-mode .dock-morph__text-send:hover:not(:disabled) {
  /* Hover = subtle dim, not a new background — keeps the bare icon
     feel even on pointer devices. */
  opacity: 0.85;
}

.iframe-mode.internal-mode .dock-morph__text-send:active:not(:disabled) {
  transform: scale(0.92);
}

.iframe-mode.internal-mode .dock-morph__text-send:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}

/* ── Sent-bubble (typed message echo) ──────────────────────────── */

/* Chat stack: container holding the two text-mode bubbles (sent +
   received). Sits in the absolutely-positioned slot above the
   dock-morph pill. Flex column so DOM order = visual stacking
   order (sent first = top; received second = bottom = closest to
   the dock, matches iMessage scroll direction). */
.dock-chat-stack {
  display: none;
}

/* Chat stack lives as a SIBLING of dock-morph inside .live__dock
   (not nested) — see the comment on the HTML element. Anchored
   absolute against .live__dock, floats just above the dock-morph
   pill. Visibility is governed entirely by each bubble's
   [hidden] attribute (both bubbles hidden = invisible empty
   stack), so we don't need a data-input-mode gate here.

   Width-locked to the procedure card's column (`min(28rem,
   calc(100vw - 2rem))` — same clamp as .live__pill[data-state^=
   "procedure-"]). That way the conversation always sits inside
   the visual column the card sits in, instead of stretching to
   the full iframe edges. */
.iframe-mode.internal-mode .dock-chat-stack {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
  width: min(28rem, calc(100vw - 2rem));
  pointer-events: none;
  z-index: 2;
  /* Each child bubble owns its own glass surface (so backdrop-
     filter samples the live video). The stack container itself
     stays a layout-only wrapper — no background, no filter. */
}

/* Narrow viewport: shrink the gutter to match the procedure
   card's mobile tightening (calc(100vw - 1rem) — see the
   @media (max-width: 30rem) block on .live__pill[data-state^=
   "procedure-"]). */
@media (max-width: 30rem) {
  .iframe-mode.internal-mode .dock-chat-stack {
    width: calc(100vw - 1rem);
  }
}

/* Shared bubble visual — both sent and received use the same
   liquid-glass surface so the conversation reads as one
   continuous material. Differences live in alignment + border-
   radius (which corner gets the "tail" 4px squared).
   Heavier white-opacity than the dock-morph pill below (0.34 vs
   0.14) because the bubbles float on the dark video without
   surrounding chrome — they need a clearly-visible glass body
   in their own right. The gradient + top inner highlight give
   the iOS-26-style "lit edge" so the bubble reads as material
   even when the backdrop-filter is software-rendered (Android
   Chrome, older Safari). */
.iframe-mode.internal-mode .dock-chat-stack .dock-sent-bubble:not([hidden]),
.iframe-mode.internal-mode
  .dock-chat-stack .dock-received-bubble:not([hidden]) {
  display: inline-flex;
  align-items: center;
  background:
    linear-gradient(
      180deg,
      rgba(255, 255, 255, 0.42) 0%,
      rgba(255, 255, 255, 0.28) 100%
    );
  border: 1px solid rgba(255, 255, 255, 0.42);
  backdrop-filter: blur(36px) saturate(200%);
  -webkit-backdrop-filter: blur(36px) saturate(200%);
  box-shadow:
    0 14px 32px rgba(0, 0, 0, 0.28),
    0 2px 6px rgba(0, 0, 0, 0.16),
    inset 0 1px 0 rgba(255, 255, 255, 0.55),
    inset 0 -1px 0 rgba(0, 0, 0, 0.06),
    inset 0 0 0 1px rgba(255, 255, 255, 0.10);
  padding: 7px 14px;
  pointer-events: none;
  animation: dock-sent-inline-in 220ms cubic-bezier(0.2, 0.95, 0.2, 1)
    both;
}

/* Sent: right-aligned in the column, tail in the bottom-right
   (iMessage sent-side vocabulary). max-width 85% of the chat
   stack so a short send like "hi" stays a small pill but a long
   typed message can wrap up to the stack's full column without
   feeling edge-to-edge. */
.iframe-mode.internal-mode .dock-chat-stack .dock-sent-bubble:not([hidden]) {
  align-self: flex-end;
  border-radius: 16px 16px 4px 16px;
  max-width: 85%;
}

.iframe-mode.internal-mode .dock-chat-stack
  .dock-sent-bubble:not([hidden]) .dock-sent-bubble__text {
  /* Full text of the most-recent send, multi-line allowed. Long
     messages wrap vertically instead of getting clipped — we only
     ever have ONE sent bubble visible at a time (a new send
     replaces it) so unbounded height is safe. */
  display: block;
  color: #ffffff;
  font-size: 14px;
  line-height: 1.35;
  letter-spacing: 0.01em;
  white-space: normal;
  overflow-wrap: anywhere;
  /* Subtle text-shadow keeps the white type legible against the
     lighter liquid-glass surface when the underlying video is
     bright (a backlit hand, a window, light skin tones). */
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
}

/* Received: left-aligned in the column, tail in the bottom-left
   (mirror of the sent vocabulary — points back down toward the
   dock from the receiver's side). Slightly wider cap than sent
   (95%) because agent replies are typically longer prose. */
.iframe-mode.internal-mode .dock-chat-stack .dock-received-bubble:not([hidden]) {
  align-self: flex-start;
  border-radius: 16px 16px 16px 4px;
  max-width: 95%;
}

.iframe-mode.internal-mode .dock-chat-stack
  .dock-received-bubble:not([hidden]) .dock-received-bubble__text {
  /* Full text of the most-recent agent reply, multi-line allowed.
     Symmetric with the sent bubble — only ONE received bubble is
     visible at a time (next user send / tap-nav clears it) so
     unbounded height is safe. */
  display: block;
  color: #ffffff;
  font-size: 14px;
  line-height: 1.35;
  letter-spacing: 0.01em;
  white-space: normal;
  overflow-wrap: anywhere;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
}

@keyframes dock-sent-inline-in {
  0% {
    opacity: 0;
    transform: translateY(4px);
  }
  100% {
    opacity: 1;
    transform: translateY(0);
  }
}

/* [hidden] safety nets — UA rule has lower specificity than the
   :not([hidden]) selectors above, so these keep the bubbles
   hidden when JS flips the attribute regardless of mode. */
.iframe-mode.internal-mode .dock-sent-bubble[hidden],
.iframe-mode.internal-mode .dock-received-bubble[hidden] {
  display: none !important;
}

/* Note: .live__dock already uses position: absolute (bottom-anchored
   to the viewport), which is itself a positioning context for the
   sent-bubble. We deliberately don't override to position:relative
   here — that would knock the dock out of bottom-anchor and let it
   fall back into document flow at the top of .live. */

/* ── Start coachmark ────────────────────────────────────────────────
   First-session onboarding hint sitting above the dock pill.
   Two paint layers inside one fixed wrapper:
     1. <button> .box   — chromeless label "Tap to talk or use
                           keyboard". No background, no border, no
                           padding box — reads as free-floating
                           instruction on the camera feed. Acts as
                           the tappable unmute affordance: tapping
                           the text goes through the same
                           toggleMute path as tapping the dock.
     2. <svg>           — hairline white connector line from the
                           text down to the top of the dock pill.
   Gated by IFRAME_MODE + flags.showStartCoachmark + one-shot
   page-load latch (see app.js > openStartCoachmark). Positioning
   math lives in positionStartCoachmark(); CSS owns paint, sizing,
   and the visible/hidden fade only. */
.start-coachmark {
  position: fixed;
  inset: 0;
  /* Wrapper is non-interactive; the <button> .box opts back in
     with pointer-events: auto. */
  pointer-events: none;
  /* Sit above the dock (z 2-4) and chat stack (z 2) but below the
     pin-overlay reticles (z 5) so a pin drop can take precedence
     visually if the agent fires one mid-hint. */
  z-index: 6;
  opacity: 0;
  transition: opacity 200ms ease;
}

.start-coachmark[hidden] {
  /* Override the .is-visible transition + the parent's flex/grid
     layout context so the dialog reliably disappears. */
  display: none !important;
}

.start-coachmark.is-visible {
  opacity: 1;
}

/* Full-viewport SVG used as a free-form canvas for the connector
   line. overflow: visible lets the stroke spill outside the SVG's
   nominal bbox (the line endpoints are written as raw viewport
   coords, no viewBox math). */
.start-coachmark__svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}

/* Hairline white connector. Solid stroke (no dashes) so it reads
   as a clean "this label points here" line rather than a
   measurement indicator. drop-shadow gives just enough contrast
   over bright camera frames (white-on-white ceilings, sky, paper,
   etc.) without needing a heavier stroke that would look
   draftsman-y. */
.start-coachmark__line {
  stroke: rgba(255, 255, 255, 0.85);
  stroke-width: 1;
  stroke-linecap: round;
  fill: none;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
}

/* Label box. Position written inline by the JS positioner
   (top + left, both in px). pointer-events: auto re-opts into
   tap handling — the wrapper above is event-transparent. No
   background fill — the text floats on the camera feed and the
   four white L-brackets below frame it without obstructing the
   view through to the camera. Padding sets the inset that the
   corner brackets sit at, so the brackets read as ~around the
   text, not glued to it. */
.start-coachmark__box {
  position: absolute;
  pointer-events: auto;
  appearance: none;
  margin: 0;
  padding: 6px 12px;
  background: none;
  border: 0;
  color: inherit;
  text-align: left;
  font: inherit;
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition: transform 100ms ease, opacity 100ms ease;
}

.start-coachmark__box:active {
  transform: scale(0.97);
  opacity: 0.85;
}

/* Camera-focus / scope corner brackets. Four 10px L-shapes,
   one per corner, sitting just OUTSIDE the box's padding edges
   so they frame the text without touching it. Drawn as two
   borders on a transparent fixed-size span — keeps the bracket
   strokes crisp at any DPR (no SVG rasterisation tradeoffs)
   and lets each corner be sized / coloured independently if we
   later want a hover / focus treatment.
   filter: drop-shadow gives just enough contrast on bright
   camera frames (white-on-white ceilings, sky) without the
   bracket looking like an opaque card edge. */
.start-coachmark__corner {
  position: absolute;
  width: 10px;
  height: 10px;
  border: 1px solid rgba(255, 255, 255, 0.95);
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.55));
  pointer-events: none;
}
.start-coachmark__corner--tl {
  top: -1px;
  left: -1px;
  border-right: none;
  border-bottom: none;
}
.start-coachmark__corner--tr {
  top: -1px;
  right: -1px;
  border-left: none;
  border-bottom: none;
}
.start-coachmark__corner--bl {
  bottom: -1px;
  left: -1px;
  border-right: none;
  border-top: none;
}
.start-coachmark__corner--br {
  bottom: -1px;
  right: -1px;
  border-left: none;
  border-top: none;
}

/* The label itself. Bold "Tap to talk", regular "or use keyboard"
   on the same line. Per design: same colour + same size for the
   bold and regular spans — weight is the only visual difference.
   white-space: nowrap keeps the whole instruction on a single
   line; the parent box width grows to fit.
   text-shadow does the heavy lifting for legibility on bright
   camera frames — without a backing box we rely entirely on this
   to keep the white text readable against ceilings / windows /
   faces / clothing. */
.start-coachmark__label {
  margin: 0;
  font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
  font-size: 14px;
  line-height: 1.3;
  letter-spacing: 0.01em;
  color: #ffffff;
  text-shadow:
    0 1px 2px rgba(0, 0, 0, 0.85),
    0 0 4px rgba(0, 0, 0, 0.4);
  white-space: nowrap;
  font-weight: 400;
}

.start-coachmark__label strong {
  font-weight: 700;
  /* Explicitly same colour + same size as the surrounding text —
     weight is the only difference. */
  color: inherit;
  font-size: inherit;
}

