: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;
  --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.                       */

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 ────────────────────────────────────────────────── */

.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). */

@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. */

/* ─── 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. */

/* ─── 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. */

/* ─── 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-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. */

/* 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. */
/* .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. */

@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. */

/* 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; }

/* RESPONDING: rounded card, rainbow border re-appears (the rotating
   conic-gradient ::before) as the "actively talking" cue. */

/* 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. */

/* Make the response field expand in card mode the same way it does
   in responding mode — same animation, same max-height. */

.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);
}

/* 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. */

/* 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::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. */

/* ─── 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;
  width: 26px;
  height: 26px;
  flex: 0 0 auto;
  transition: opacity 180ms ease, transform 220ms ease;
}

/* Packed PNGs (CC, muted mic) tinted via mask so they follow
   currentColor — white on the dark chrome, dark on the light FAB. */
.chrome-icon {
  display: block;
  flex: 0 0 auto;
  background-color: currentColor;
  pointer-events: none;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: contain;
  mask-size: contain;
}
.chrome-icon--cc {
  width: 22px;
  height: 22px;
  -webkit-mask-image: url("/icons/closed-caption.png");
  -webkit-mask-image: -webkit-image-set(
    url("/icons/closed-caption.png") 1x,
    url("/icons/closed-caption@2x.png") 2x
  );
  mask-image: url("/icons/closed-caption.png");
  mask-image: image-set(
    url("/icons/closed-caption.png") 1x,
    url("/icons/closed-caption@2x.png") 2x
  );
}
.chrome-icon--mic-off {
  width: 26px;
  height: 26px;
  -webkit-mask-image: url("/icons/microphone-off.png");
  -webkit-mask-image: -webkit-image-set(
    url("/icons/microphone-off.png") 1x,
    url("/icons/microphone-off@2x.png") 2x
  );
  mask-image: url("/icons/microphone-off.png");
  mask-image: image-set(
    url("/icons/microphone-off.png") 1x,
    url("/icons/microphone-off@2x.png") 2x
  );
}
.iframe-gate__chip .chrome-icon--cc {
  width: 18px;
  height: 18px;
}
.iframe-gate__chip .chrome-icon--mic-off {
  width: 16px;
  height: 16px;
}

/* 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__waveform {
  display: flex;
  position: relative;
  z-index: 1;
  align-items: center;
  justify-content: center;
  gap: 4px;
  width: 24px;
  height: 24px;
}

/* Rest pose is three short capsules (the "dots"). Speech grows
   them on Y into equalizer bars; scaleY(1) is silence. */
.dock-btn__waveform > span {
  width: 5px;
  height: 5px;
  border-radius: 999px;
  background: currentColor;
  transform-origin: center;
  animation: listening-wave 1.05s ease-in-out infinite;
}

.dock-btn__waveform > span:nth-child(1),
.dock-btn__waveform > span:nth-child(3) { animation-delay: -0.28s; }
.dock-btn__waveform > span:nth-child(2) {
  animation-delay: 0s;
  animation-name: listening-wave-center;
}

/* 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. Fallback 1 is the rest/dot pose. */
.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, 1));
}
.dock-btn__waveform.is-reactive > span:nth-child(2) {
  transform: scaleY(var(--bar-2, 1));
}
.dock-btn__waveform.is-reactive > span:nth-child(3) {
  transform: scaleY(var(--bar-3, 1));
}

/* 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. */

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

@keyframes listening-wave {
  0%, 100% {
    transform: scaleY(1);
    opacity: 0.85;
  }
  45% {
    transform: scaleY(2.2);
    opacity: 1;
  }
}
@keyframes listening-wave-center {
  0%, 100% {
    transform: scaleY(1);
    opacity: 0.85;
  }
  45% {
    transform: scaleY(3.4);
    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. */

/* Topic group */

/* 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. */

/* Expandable variant. <details> wraps the whole row contents — the
   <summary> renders the same shape as a plain row, click to open. */

/* ─── 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. */

/* 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. */

@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-dots {
  display: none;
}

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

.procedure-card__kicker,
.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__step-label {
  font-weight: 500;
  color: #14181f;
}


/* 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;
  /* Lighter than the header title (600) so the step sentence reads as
     body copy, not a second heading of equal weight. */
  font-weight: 300;
  line-height: 1.5;
  color: #14181f;
}

/* 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;
  /* Push the prev/next chevrons to the far edges with the dots in the
     middle — clear "back on the left, next on the right" affordance for
     stepping through a long procedure. */
  justify-content: space-between;
  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;
  /* Faint resting pill so the arrows are visible on either side even
     before hover (touch has no hover). */
  background: rgba(255, 255, 255, 0.08);
  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;
  /* Wrap instead of overflowing: a long procedure's dots used to run
     off the row and shove the next/prev chevrons out of the card, so
     there was no visible way to page a many-step card. Wrapping keeps
     the arrows pinned on either side (they're flex: 0 0 32px) and lets
     the dots stack between them. */
  flex-wrap: wrap;
  align-content: center;
  justify-content: center;
  flex: 1 1 auto;
  min-width: 0;
  max-height: 3.4rem;
  overflow-y: 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; }
}

/* ── 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. */

/* 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. */

/* Mobile / narrow viewport: shrink record card so it doesn't
   crowd the dock. Mirrors the procedure-card responsive rules. */

/* ── /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).
 */

/* ── 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.
 */

/* The literal prompt text. Bigger / brighter than the Prompt
   Template body below so the eye lands here first. */

/* ─────────────────────────────────────────────────────────────────
 * 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) ───────────── */

/* 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. */

/* ── ASA chips on glass ─────────────────────────────────────────── */

/* ── 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);
}

/* 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);
}

/* 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);
}

/* "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. */

/* ── Dock-pill: Live Guidance gate ──────────────────────────────── */

/* ── 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;
}

/* Harness-only extras on the black Share gate. Live ?source=iframe
   keeps a single Share pill; ?harness=true stamps `.harness-mode` at
   first paint so Cancel + terms commit with the black background. */
.iframe-gate__actions {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  gap: 1.1rem;
}

.btn--iframe-cancel {
  appearance: none;
  border: 0;
  background: transparent;
  box-shadow: none;
  cursor: pointer;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 0.35rem 0.15rem;
  color: rgba(248, 250, 252, 0.78);
  font-size: 0.95rem;
  font-weight: 500;
  letter-spacing: 0.01em;
  transition: color 0.15s ease;
}
.btn--iframe-cancel:hover { color: #ffffff; }
.btn--iframe-cancel:active { color: rgba(248, 250, 252, 0.7); }
.btn--iframe-cancel:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 3px;
  border-radius: 4px;
}

.iframe-gate__terms {
  display: none;
  margin: 0.35rem 0 0;
  max-width: 22rem;
  color: rgba(248, 250, 252, 0.72);
  font-size: 0.78rem;
  line-height: 1.45;
  text-align: center;
}

.iframe-gate__intro {
  display: none;
  max-width: 22rem;
  margin: 0 0 0.35rem;
  text-align: center;
}
.iframe-gate__lede {
  margin: 0;
  color: #f8fafc;
  font-size: 1.15rem;
  font-weight: 700;
  letter-spacing: 0.01em;
  line-height: 1.35;
}

.iframe-gate__guide {
  list-style: none;
  margin: 0.9rem 0 0;
  padding: 0;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.55rem 0.75rem;
  text-align: left;
}
.iframe-gate__guide-item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  color: rgba(248, 250, 252, 0.86);
  font-size: 0.78rem;
  line-height: 1.25;
}
.iframe-gate__chip {
  flex: 0 0 32px;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: #ffffff;
  border: 1px solid rgba(255, 255, 255, 0.45);
  background: rgba(15, 23, 42, 0.55);
}
.iframe-gate__chip--mic-off {
  border-color: rgba(255, 255, 255, 0.6);
}
.iframe-gate__chip--mic-on {
  background: rgba(255, 255, 255, 0.92);
  border-color: rgba(255, 255, 255, 0.9);
  color: #0f172a;
}
.iframe-gate__wave {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 2px;
  height: 14px;
}
.iframe-gate__wave span {
  display: block;
  width: 3px;
  border-radius: 999px;
  background: currentColor;
}
.iframe-gate__wave span:nth-child(1),
.iframe-gate__wave span:nth-child(3) { height: 7px; }
.iframe-gate__wave span:nth-child(2) { height: 14px; }

.harness-mode #iframe-cancel {
  display: inline-flex;
}
.harness-mode #iframe-gate-intro {
  display: block;
}
.harness-mode #iframe-gate-terms {
  display: block;
}

/* Short iframe (setup-card leftover height ~350px): pack the Share
   gate so intro + controls + Share + terms fit without clipping.
   Tall embeds (chat overlay) keep the existing centered layout. */
@media (max-height: 440px) {
  .harness-mode #iframe-gate {
    justify-content: flex-start;
    gap: 0.4rem;
    padding: 0.6rem 0.7rem;
    overflow-y: auto;
  }
  .harness-mode .iframe-gate__intro {
    margin: 0;
    max-width: 100%;
  }
  .harness-mode .iframe-gate__lede {
    font-size: 0.95rem;
    line-height: 1.25;
  }
  .harness-mode .iframe-gate__guide {
    margin: 0.4rem 0 0;
    gap: 0.3rem 0.45rem;
  }
  .harness-mode .iframe-gate__guide-item {
    font-size: 0.7rem;
    gap: 0.35rem;
  }
  .harness-mode .iframe-gate__chip {
    flex-basis: 26px;
    width: 26px;
    height: 26px;
  }
  .harness-mode .iframe-gate__chip .chrome-icon--cc {
    width: 14px;
    height: 14px;
  }
  .harness-mode .iframe-gate__chip .chrome-icon--mic-off {
    width: 13px;
    height: 13px;
  }
  .harness-mode .btn--iframe-share {
    padding: 0.55rem 1.4rem;
    font-size: 0.9rem;
  }
  .harness-mode .iframe-gate__actions {
    gap: 0.75rem;
  }
  .harness-mode .iframe-gate__terms {
    margin: 0.1rem 0 0;
    font-size: 0.68rem;
    line-height: 1.3;
    max-width: 100%;
  }
}

/* ─── Pre-hydration gate selection ───────────────────────────────────
   The inline <head> script in index.html sets `.iframe-mode` on
   <html> for ?source=iframe or ?harness=true 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;
}

/* Waveform becomes its own circular "avatar" inside the expanded
   container. The base .dock-btn__waveform is a 24×24 inline flex
   of 3 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). */

/* ─── 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. */

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

/* 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. */

/* Toggle = segmented [🎙️ | ⌨️] pill. Always visible in internal mode,
   regardless of which content side is active. */

/* 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. */

/* Clip the waveform animation to the 32×32 button circle. Bars
   scale up to ~3.4x on transients; without `overflow: hidden` they
   visibly burst past the round button edge (and past the parent
   toggle capsule's border-radius). */

/* 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. */

/* 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. */

/* 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. */

/* 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. */

/* 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. */

/* ── 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. */

/* 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. */

/* In voice mode the text-area shouldn't even claim a flex slot, so
   the toggle sits flush against the mute-btn avatar. */

/* 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. */

/* In text mode, the mic FAB is replaced by the text input row. */

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

/* ── Proactive suggested-prompt cards ─────────────────────────── */

/* Tap-dismiss: no more taps land while the rail fades out. */

/* The unpicked cards clear out of the way instantly, but keep their
   layout slot (opacity, not display) so the picked card fades in place
   without recentering. */

/* The picked card highlights (brighten + a small pop) then fades out. */

/* ── 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). */

/* 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. */

/* Internal text mode uses a messaging hierarchy: conversation first,
   proactive replies second, input last. When suggestions are visible,
   lift the conversation stack by one card row so it sits above the
   carousel instead of competing for the same absolute dock slot. Voice
   mode has no chat bubbles, so the shared suggestion rail stays in its
   original dock-adjacent position. */

/* 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-"]). */

/* 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). */

/* 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. */

/* 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. */

/* [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. */

/* 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;
}

/* ── Device-identification scan reticle (asa_mode=1) ────────────────
   Centred corner-lock indicator for identify_device. Four thin
   L-shaped brackets frame the middle of the camera view and carry a
   soft, breathing glow for the whole scan — an ambient "I'm looking"
   cue, not a progress gauge. There is no on-screen text and no
   per-level thickening; voice carries the outcome. The element fades
   in on the first "searching" message and fades out on match (after a
   brief lock pulse) or no_match. State is data-phase driven so the JS
   just toggles .is-visible / data-phase and CSS owns the paint. */

/* Corner brackets. Each corner is an L (two borders on a transparent
   box) so the strokes stay crisp at any DPR. Thin, white, and lit by a
   subtle pulsing glow (drop-shadow follows the L shape, not a hard box
   shadow). A match swaps the ambient pulse for a one-shot bright lock
   glow; no_match just fades the whole reticle out. */

/* Ambient "searching" pulse: a soft white glow that breathes in and
   out. Subtle by design — a gentle blur/glow, not a strobe. */

/* Match: one bright bloom, then settle — reads as "locked on". */

/* 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;
}

/* ─── Global-launcher chrome (?global=true) ──────────────────────────
   Only paints when .global-mode is set (inline first-paint script in
   index.html + mirrored in app.js). Two surfaces:
     - .global-controls: a bottom row that FLANKS the centered mic FAB
       — CC on the left, End (X) on the right. The container is
       pointer-events:none so its transparent middle never eats taps
       meant for the mic FAB that sits (in .live__dock) underneath it.
     - .global-captions: a translucent caption pill above the dock,
       carrying the agent's spoken words when CC is on.
   Everything keeps `[hidden]` authoritative via :not([hidden]) so JS
   can hide the caption bar between turns without a display fight. */
.global-controls,
.global-captions {
  display: none;
}

.global-mode .global-controls:not([hidden]) {
  position: absolute;
  left: 0;
  right: 0;
  /* Match the iframe-mode dock's bottom EXACTLY (global always runs
     inside ?source=iframe) so the CC / End buttons share the mic
     FAB's baseline — the earlier `max(1.25rem, …)` sat higher than
     the iframe dock and knocked the trio out of line. */
  bottom: clamp(0.5rem, 2.5vh, 1.25rem);
  /* Full-width transparent band; the CC / End buttons are absolutely
     positioned symmetrically around the horizontal centre so they sit
     tight beside the centered mic FAB (which lives in .live__dock).
     Above the dock (z 4) so the buttons are tappable; the band itself
     passes taps through to the mic FAB in its transparent middle. */
  display: block;
  height: 44px;
  z-index: 5;
  pointer-events: none;
}

@supports (height: 100svh) {
  .global-mode .global-controls:not([hidden]) {
    bottom: calc(clamp(0.5rem, 2.5vh, 1.25rem) + (100vh - 100svh));
  }
}

/* Group the trio tight and in line: all three are 44px tall,
   bottom-aligned to the same dock baseline. CC sits 10px left of the
   mic FAB (44px → half is 22 → 22 + 10 = 32px from centre), End 10px
   to its right. */
.global-controls__btn--cc {
  position: absolute;
  bottom: 0;
  right: calc(50% + 32px);
}

.global-controls__btn--end {
  position: absolute;
  bottom: 0;
  left: calc(50% + 32px);
}

/* On the global launcher the composer already offers input; the
   floating suggested-utterance rail just crowds the caption bar and
   the tight control cluster, so hide it entirely. */

.global-controls__btn {
  pointer-events: auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.55);
  color: #ffffff;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition:
    background-color 160ms ease,
    backdrop-filter 160ms ease,
    border-color 160ms ease,
    transform 120ms ease;
}

.global-controls__btn:hover {
  background: rgba(0, 0, 0, 0.72);
}

.global-controls__btn:active {
  transform: scale(0.94);
}

.global-controls__btn:focus-visible {
  outline: 2px solid #ffffff;
  outline-offset: 2px;
}

/* CC "on" state: liquid-glass pill (same vocabulary as the response
   pill), NOT a solid white fill — reads as pressed while staying in
   the translucent chrome language. */
.global-controls__btn--cc.is-active {
  background: rgba(255, 255, 255, 0.18);
  -webkit-backdrop-filter: blur(20px) saturate(1.5);
  backdrop-filter: blur(20px) saturate(1.5);
  border-color: rgba(255, 255, 255, 0.4);
  color: #ffffff;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.4),
    inset 0 -1px 0 rgba(255, 255, 255, 0.06);
}

/* The caption bar is the top response pill relocated to the bottom:
   same liquid-glass vocabulary (fill / blur / border / shadow / white
   text) as .live__pill[data-state="responding"] in iframe mode, so
   it reads as "the exact non-CC text, just at the bottom". */
.global-mode .global-captions:not([hidden]) {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(clamp(0.5rem, 2.5vh, 1.25rem) + 3.5rem);
  max-width: min(88%, 28rem);
  display: block;
  padding: 0.55rem 1rem 0.7rem;
  border-radius: 24px;
  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);
  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);
  z-index: 5;
  pointer-events: none;
}

@supports (height: 100svh) {
  .global-mode .global-captions:not([hidden]) {
    bottom: calc(clamp(0.5rem, 2.5vh, 1.25rem) + 3.5rem + (100vh - 100svh));
  }
}

.global-captions__text {
  /* Match the response pill's type: white-on-glass, same size/weight. */
  font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
  font-size: 0.9rem;
  font-weight: 600;
  line-height: 1.35;
  color: rgba(255, 255, 255, 0.96);
  text-align: center;
  /* Cap the caption so a long turn doesn't grow the pill up into the
     video. */
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ─── Harness (?harness=true) mic FAB visibility ─────────────────────
   The non-live harness reuses the ?global=true chrome but WITHOUT
   app.js's audio-reactive FAB driver, and frequently runs over a dark
   or blank camera. The production iframe-mode FAB is a deliberately
   translucent liquid-glass circle (rgba(255,255,255,0.14) unmuted,
   rgba(15,23,42,0.45) muted) that reads as invisible against a dark
   scene here — the reported "tap to mute didn't show". Give the native
   #mute-btn a solid, high-contrast treatment (same vocabulary as the
   standalone FAB) so it's an unmistakable 64px tap target. Scoped to
   .harness-mode (set only by harness-global.js), so real ?global=true
   is untouched. Placed at end-of-file so it wins on source order over
   the equal-specificity `.iframe-mode .dock-btn--mic` rules above. */
.harness-mode .dock-btn--mic {
  /* Match the 44px CC / End buttons on either side. */
  width: 44px;
  min-width: 44px;
  max-width: 44px;
  height: 44px;
  border-radius: 50%;
  /* Muted (default) = translucent dark disc with a crisp light ring, so
     it always reads over any scene — matches the reference. */
  background: rgba(15, 23, 42, 0.55);
  border: 1px solid rgba(255, 255, 255, 0.6);
  color: #ffffff;
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.34);
  opacity: 1;
}

/* Live (unmuted) = bright disc so the "hot mic" state is unmistakable. */
.harness-mode .dock-btn--mic[data-muted="false"] {
  background: rgba(255, 255, 255, 0.92);
  border-color: rgba(255, 255, 255, 0.9);
  color: #0f172a;
}

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

/* Harness-only ✕ on the procedure card (the live card is agent-dismissed
   and has none). Absolutely pinned top-right of the morphed pill; the
   header gets extra right padding so a long title never runs under it. */
.harness-mode .procedure-card__header {
  padding-right: 2.75rem;
}
.harness-mode .harness-proc-close {
  position: absolute;
  top: 0.6rem;
  right: 0.6rem;
  z-index: 2;
  width: 1.6rem;
  height: 1.6rem;
  padding: 0;
  border: none;
  border-radius: 999px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 0.72rem;
  line-height: 1;
  color: rgba(255, 255, 255, 0.9);
  background: rgba(255, 255, 255, 0.16);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  transition: background 0.15s ease;
}
.harness-mode .harness-proc-close:hover {
  background: rgba(255, 255, 255, 0.28);
}
