/* ═══════════════════════════════════════════════════════════════════════════
 * Voice Infra console — the MAGI design language, cut for a white canvas.
 *
 * MAGI (magi/web/src/index.css) is an operator instrument. This is a
 * customer-facing sandbox. Same company, same vocabulary, different room:
 * the type scale, the single hairline weight, the 2–4px radii, the two
 * semantic colours, the ease curve and the press feedback are all MAGI's.
 * What is NOT ported is MAGI's dark mode — this surface stays paper-white on
 * purpose, so the light values are chosen against paper rather than inverted
 * out of the dark ones.
 *
 * THREE RULES CARRIED OVER VERBATIM, because they are what makes MAGI legible:
 *
 *   1. ONE HAIRLINE. Every edge in the UI is 1px of --c-line (or --c-line-strong
 *      when it is a thing you can press). There are no shadows outside the two
 *      modals. Depth is drawn with a line and a fill, never with a blur.
 *
 *   2. MEANING IS NEVER IN HUE ALONE. The two semantic colours — alarm (red,
 *      "this is broken") and open (amber, "unresolved / not settled") — are
 *      indistinguishable to a deuteranope at these lightnesses. So every state
 *      also carries a SHAPE: ○ hollow ring = fine, ▨ amber hatch = borderline,
 *      ■ solid = bad. Learned once in the latency table, reused everywhere.
 *
 *   3. NO AMBIENT MOTION. Nothing loops, nothing pulses, nothing performs the
 *      page on arrival. Motion is reserved for a thing that actually just
 *      happened: a transcript line landing, a modal opening, a button being
 *      pressed. This is a screen people leave open while they test.
 * ═══════════════════════════════════════════════════════════════════════════ */

:root {
  /* ── MAGI tokens · the source of truth ─────────────────────────────────
   * Lifted from magi/web/src/index.css. Anything below this block that needs
   * a colour references one of these; a hex code appearing twice in this file
   * is a bug. */

  --c-paper: #fbfaf8;          /* the canvas — warm, not blue-white */
  --c-surface: #ffffff;        /* anything that sits ON the canvas */
  --c-sunk: #f4f2ee;           /* row hover, bot bubbles, inert fills */
  --c-line: #e7e4dd;           /* the hairline */
  --c-line-strong: #d3cec4;    /* the hairline on something pressable */

  --c-ink: #1b1a18;            /* 15.4:1 on paper */
  --c-ink-2: #45433d;          /*  9.5:1 — body copy */
  /* MAGI's ink-3 is #7a7770, which is 4.28:1 on paper — under AA. It is fine
   * there because MAGI uses it for decoration; here it carries real text
   * (labels, help copy, timestamps), so it is darkened to 5.0:1. */
  --c-ink-3: #6f6c65;
  --c-ink-4: #a8a49c;          /* 2.4:1 — DECORATION ONLY. Never text. */

  --c-alarm: #ab2317;
  --c-alarm-ink: #8d1d13;      /* 8.7:1 */
  --c-alarm-bg: #fdf2f0;
  --c-alarm-bg-hover: #fbe8e5;
  --c-alarm-line: rgba(171, 35, 23, 0.4);

  --c-open: #8a5900;
  --c-open-ink: #7a4f00;       /* 6.8:1 */
  --c-open-bg: #fdf7ea;
  --c-open-line: rgba(138, 89, 0, 0.45);
  --c-open-hatch: rgba(138, 89, 0, 0.2);

  /* The dark rail and the founder's note are MAGI's DARK-mode surface values,
   * not an invented dark green. Two halves of one palette on one page. */
  --c-dark: #151513;
  --c-dark-sunk: #1f1e1b;
  --c-dark-line: #2b2a24;
  --c-on-dark: #f1efe9;
  --c-on-dark-2: #c4c0b6;
  --c-on-dark-3: #8b877d;
  --c-alarm-on-dark: #ff8073;

  /* The one brand colour that survives the port, and it survives in exactly
   * two places: the ✳ mark and the brand pill. MAGI has no accent at all;
   * green here says "mirai minds", never "good", never "primary". */
  --accent: #0f6a44;           /* 6.1:1 on paper */
  --accent-on-dark: #4fc08a;
  --accent-bg: #edf5f0;
  --accent-line: #c6ded1;

  /* The sheet scrim. Same channels as --c-ink at a third opacity — the only
   * dimming surface in the build, and it exists so a bottom sheet reads as a
   * layer over the app rather than a second page. Declared here for the same
   * reason --c-alarm-line is: an rgba() of a token is still the token. */
  --c-scrim: rgba(27, 26, 22, 0.34);

  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

  /* ── phone shell metrics ────────────────────────────────────────────────
   * The phone layout is three fixed edges (title bar, call dock, tab bar) and
   * one scrolling middle. The call pane is absolutely positioned between them,
   * so its insets have to be stated somewhere both it and the chrome can read.
   * --dock-h is a fallback: core.js measures the real dock with a
   * ResizeObserver, because it grows when the dial group is opened, when the
   * queue position wraps and when the download link appears. */
  --mtop-h: 46px;
  --tabbar-h: calc(54px + env(safe-area-inset-bottom));
  --dock-h: 152px;
  /* The height of the VISIBLE part of the screen. Starts as dvh and is
   * overwritten by core.js from visualViewport, which is the only number that
   * shrinks when the iOS keyboard comes up — dvh does not. */
  --vvh: 100dvh;

  /* Radii. MAGI's whole scale is 2–4px and nothing else; a 16px card corner is
   * the single loudest tell that two products were drawn by different people. */
  --r-xs: 2px;                 /* bars, meters, swatches */
  --r-sm: 3px;                 /* buttons, inputs, pills */
  --r-md: 4px;                 /* cards, panes, modals */

  --sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto,
          'Helvetica Neue', Arial, sans-serif;
  --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
          'Liberation Mono', monospace;

  /* ── aliases ───────────────────────────────────────────────────────────
   * The rules below were written against these names. They now resolve to
   * MAGI tokens, so the palette has exactly one definition site. */
  --canvas: var(--c-paper);
  --surface: var(--c-surface);
  --surface-soft: var(--c-sunk);
  --surface-strong: var(--c-line);
  --hairline: var(--c-line);
  --hairline-soft: var(--c-line);   /* ONE hairline weight — see rule 1 */
  --ink: var(--c-ink);
  --body: var(--c-ink-2);
  --muted: var(--c-ink-3);
  --dark: var(--c-dark);
  --on-dark: var(--c-on-dark);
  --on-dark-muted: var(--c-on-dark-3);
  --warn: var(--c-open-ink);
  --bad: var(--c-alarm-ink);
  --ease: var(--ease-out);
  --ease-hover: var(--ease-out);
  --serif: var(--sans);        /* no webfont: see the type note below */
}

/* TYPE. MAGI runs on the system sans and nothing else, so this console does
 * too — which also removes the two Google Fonts requests that used to sit in
 * the <head>. Newsreader and Space Grotesk are gone; the display treatment
 * they were carrying (large, tight, tabular) is now done with tracking and
 * weight on the system face, which is exactly how MAGI does it. */

* { box-sizing: border-box; }

html { background: var(--canvas); }

body {
  font-family: var(--sans);
  background: var(--canvas); color: var(--c-ink);
  margin: 0; min-height: 100vh; font-size: 13px; line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* Every figure that sits in a column has to line up. Proportional digits make
 * a latency table unreadable. */
.tnum, .hist-stat strong, .hist-when, .hist-to, #cap, #phone {
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

/* Tracking is size-specific — large figures read loose at default spacing,
 * small uppercase labels read tight. Never one value for all. */
.display { letter-spacing: -0.025em; line-height: 0.95; }

/* A label that exists for the screen reader and the accessibility tree only.
 * Used on the icon-only phone buttons; clip-path rather than display:none so
 * the name is actually announced. */
.vh { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
      overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }

/* ── the state marks ───────────────────────────────────────────────────────
 * RULE 2 LIVES HERE. alarm and open are near-identical to a deuteranope, so
 * each is pinned to a shape as well as a colour, and the calm case gets its
 * own shape too rather than being "the one with no mark":
 *
 *   ○  hollow ink-4 ring   — fine. A real answer, quietly stated.
 *   ▨  amber diagonal hatch — borderline / unresolved. Never a calm grey.
 *   ■  solid alarm square   — bad. The darkest, loudest mark available.
 *
 * The hatch is MAGI's, gradient for gradient, so somebody who has seen the
 * fleet board already knows what it means here. It never animates. */
.mark-ok::before,
.mark-open::before,
.mark-bad::before {
  content: ''; display: inline-block; width: 8px; height: 8px;
  margin-right: 6px; vertical-align: 0;
  border: 1px solid; flex-shrink: 0;
}
.mark-ok::before {
  border-color: var(--c-ink-4); border-radius: 50%; background: transparent;
}
.mark-open::before {
  border-color: var(--c-open-line); border-radius: 1px;
  background-color: var(--c-open-bg);
  background-image: repeating-linear-gradient(45deg,
    var(--c-open-hatch) 0 3px, transparent 3px 7px);
}
.mark-bad::before {
  border-color: var(--c-alarm); border-radius: 1px;
  background: var(--c-alarm);
}

/* ── app shell: nav · content · test rail ─────────────── */
.app { display: grid; grid-template-columns: 216px minmax(0, 1fr) 388px;
       gap: 16px; padding: 16px clamp(12px, 2vw, 24px);
       max-width: 1760px; margin: 0 auto; align-items: start; }

/* ── left nav (MAGI's dark surface) ───────────────────── */
/* No entrance animation. Opening a console should show you its state
 * instantly, not perform it — the same reason MAGI's alarm rail mounts with
 * `initial={false}`. */
/* dvh, not vh: on a phone-sized window vh is measured against the URL bar
 * being collapsed, so a 100vh column is always taller than the screen. */
.nav { position: sticky; top: 16px; height: calc(100vh - 32px);
       height: calc(100dvh - 32px);
       display: flex; flex-direction: column; gap: 2px;
       background: var(--c-dark); border: 1px solid var(--c-dark-line);
       border-radius: var(--r-md); padding: 16px 10px 12px; }
.logo { display: flex; align-items: center; gap: 9px; padding: 0 6px 18px; }
/* The product's mark, at a fixed HEIGHT with auto width — it is 174×112, and
 * constraining the other axis is how a logo ends up squashed on one screen and
 * not another. `flex: none` because a flex item with an intrinsic size will
 * otherwise give it up to the wordmark on a narrow rail. */
.logo .mark { height: 22px; width: auto; flex: none; display: block; }
.logo-text { display: flex; flex-direction: column; gap: 1px; }
.logo-text b { font-size: 14px; font-weight: 600; letter-spacing: -0.02em;
               color: var(--c-on-dark); }
.logo-text span { font-style: italic; font-size: 11px;
                  color: var(--c-on-dark-3); }
.nav-sec { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
           color: var(--c-on-dark-3); font-weight: 600; line-height: 1;
           padding: 0 8px 8px; }
/* The rail carries three sections now — Workspace, Models, Admin — and .nav-sec
 * only ever had padding UNDER it, so the second heading sat 2px below the last
 * item of the first list and read as its footnote. The gap is what makes them
 * two lists. It is on the heading rather than on the list so a hidden section
 * (Admin, for a customer) takes its space with it. */
.nav-items + .nav-sec { padding-top: 16px; }
.nav-items { display: flex; flex-direction: column; gap: 1px; }
/* "new", beside the item it applies to. A WORD, not a coloured dot: this has
 * to survive greyscale and say what it means with no legend (rule 2), and a
 * dot on a nav item is indistinguishable from an unread badge. margin-left:
 * auto pushes it to the far edge, which is where a status belongs and where it
 * cannot be mistaken for part of the label. */
.nav-new { margin-left: auto; font-size: 9px; letter-spacing: 0.06em;
           text-transform: uppercase; font-weight: 600; line-height: 1;
           padding: 3px 5px; border-radius: var(--r-xs);
           color: var(--c-on-dark-2); border: 1px solid var(--c-dark-line); }
.nav-item.on .nav-new { color: var(--c-on-dark); border-color: var(--c-on-dark-3); }
.nav-item { position: relative; display: flex; align-items: center; gap: 10px;
            text-align: left; background: none; border: 1px solid transparent;
            border-radius: var(--r-sm); padding: 8px 10px; cursor: pointer;
            color: var(--c-on-dark-2); font-family: inherit; font-size: 12.5px;
            font-weight: 500; line-height: 1.2; width: 100%;
            transition: background-color 90ms ease, color 90ms ease; }
.nav-item svg { width: 15px; height: 15px; flex-shrink: 0; opacity: 0.8; }
@media (hover: hover) and (pointer: fine) {
  .nav-item:hover { background: var(--c-dark-sunk); color: var(--c-on-dark); }
}
/* Selected = MAGI's Rail, tone `ink`: a 3px solid edge in the strongest
 * available value. On a dark ground the strongest value is the paper one. */
.nav-item.on { background: var(--c-dark-sunk); color: var(--c-on-dark); }
.nav-item.on::before { content: ''; position: absolute; left: -1px; top: 6px;
                       bottom: 6px; width: 3px; background: var(--c-on-dark); }
.nav-item.on svg { opacity: 1; }

.nav-foot { margin-top: auto; display: flex; flex-direction: column; gap: 12px;
            padding: 14px 4px 2px; border-top: 1px solid var(--c-dark-line); }
.nav-cap { display: flex; flex-direction: column; gap: 6px; padding: 0 4px; }
.nav-cap-row { display: flex; justify-content: space-between; align-items: baseline;
               gap: 8px; font-size: 11px; color: var(--c-on-dark-3); }
.nav-cap-row b { color: var(--c-on-dark); font-size: 12px; font-weight: 600; }
/* At capacity the number changes colour AND weight AND gains a solid mark —
 * three axes, per rule 2, because "10/10" is the moment a call will not start. */
.nav-cap-row b.full { color: var(--c-alarm-on-dark); font-weight: 700; }
.nav-cap-row b.full::before {
  content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 1px;
  margin-right: 5px; background: var(--c-alarm-on-dark);
}
.nav-cap-row.sub { font-size: 10.5px; min-height: 13px; }
.nav-cap-row.sub span:last-child { color: var(--c-on-dark-2); }
.capbar { height: 4px; background: rgba(241, 239, 233, 0.1);
          border-radius: var(--r-xs); overflow: hidden; }
.capbar i { display: block; height: 100%; width: 0%; background: var(--c-on-dark-2);
            border-radius: var(--r-xs); transition: width 200ms var(--ease-out); }
.capbar i.full { background: var(--c-alarm-on-dark); }
.meetbtn { display: flex; align-items: center; justify-content: center; gap: 7px;
           background: transparent; color: var(--c-on-dark);
           border: 1px solid var(--c-dark-line);
           font-size: 12px; font-weight: 500; line-height: 1; padding: 9px 12px;
           border-radius: var(--r-sm); text-decoration: none;
           transition: background-color 90ms ease; }
.meetbtn:hover { background: var(--c-dark-sunk); }
/* An <svg> with no width defaults to 300×150 and detonates the row. The
 * calendar glyph replaced an emoji, and it is drawn at the nav's stroke weight
 * so it reads as one hand with the icons above it. */
.meetbtn svg { width: 14px; height: 14px; flex-shrink: 0; }
.nav-me { display: flex; align-items: center; justify-content: space-between;
          gap: 8px; padding: 0 4px; }
.nav-me #brandpill { font-size: 11px; font-weight: 500; line-height: 1;
                     color: var(--accent-on-dark);
                     background: transparent;
                     border: 1px solid rgba(79, 192, 138, 0.4);
                     border-radius: var(--r-sm); padding: 4px 7px; }
.nav-me .signout { color: var(--c-on-dark-3); }
.nav-me .signout:hover { color: var(--c-on-dark); }

/* ── pills ────────────────────────────────────────────── */
/* MAGI's pill: a 3px rectangle, 11px medium, hairline border. Not a capsule —
 * capsules read as chips in a consumer app, rectangles read as an instrument. */
.pill { font-size: 11px; font-weight: 500; line-height: 1; color: var(--c-ink-2);
        background: var(--c-surface); border: 1px solid var(--c-line-strong);
        border-radius: var(--r-sm); padding: 4px 7px;
        font-variant-numeric: tabular-nums; }
.pill.cap::before { content: ''; display: inline-block; width: 7px; height: 7px;
                    border-radius: 50%; background: var(--c-ink-2);
                    margin-right: 6px; }
.pill.cap.full { color: var(--c-alarm-ink); border-color: var(--c-alarm-line);
                 background: var(--c-alarm-bg); font-weight: 600; }
.pill.cap.full::before { background: var(--c-alarm); border-radius: 1px; }
.pill.brand, .pill.ctx { color: var(--c-ink-2); background: var(--c-surface);
                         border-color: var(--c-line-strong); }

.signout { font-size: 11px; color: var(--c-ink-3); cursor: pointer;
           background: none; border: none; padding: 4px; font-family: inherit; }
.signout:hover { color: var(--c-ink); }

/* ── main content ─────────────────────────────────────── */
#content { min-width: 0; padding-bottom: 48px; }
.view-head { margin: 2px 0 18px; }
.view-head h1 { font-weight: 600; font-size: 22px; letter-spacing: -0.025em;
                margin: 0 0 5px; line-height: 1.12; color: var(--c-ink); }
.view-head p { margin: 0; color: var(--c-ink-2); font-size: 12.5px;
               max-width: 60ch; line-height: 1.6; }
.block, .card { background: var(--c-surface); border: 1px solid var(--c-line);
                border-radius: var(--r-md); padding: 16px 18px; }
.block { margin-bottom: 14px; }
/* MAGI's `.eyebrow`: 10px, 600, 0.08em, uppercase, ink-3. One definition,
 * reused for every label on the page. */
.block-title, label, .nav-sec, th, #chatpane h2, .latwrap summary,
.hist-stat span, .founder-kicker, .rail-fact-k {
  font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
  font-weight: 600; line-height: 1; color: var(--c-ink-3);
}
.block-title { margin-bottom: 12px; }
.block-sub { margin: -4px 0 12px; font-size: 12px; color: var(--c-ink-3);
             line-height: 1.55; }
.card h3 { margin: 0 0 4px; font-size: 13px; font-weight: 600;
           letter-spacing: -0.01em; }
.context-strip { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
                 margin-bottom: 14px; }

/* ── form primitives ──────────────────────────────────── */
select, input[type=text], input[type=tel] {
  background: var(--c-surface); color: var(--c-ink);
  border: 1px solid var(--c-line-strong);
  border-radius: var(--r-sm); padding: 7px 9px; font-size: 12.5px;
  font-family: inherit; line-height: 1.3;
  transition: border-color 90ms ease;
}
input::placeholder, textarea::placeholder { color: var(--c-ink-3); }
textarea { width: 100%; background: var(--c-surface); color: var(--c-ink);
           border: 1px solid var(--c-line-strong); border-radius: var(--r-sm);
           padding: 9px 11px; font-size: 12.5px; resize: vertical;
           min-height: 72px; font-family: inherit; line-height: 1.6;
           transition: border-color 90ms ease; }
select:hover, input:hover, textarea:hover { border-color: var(--c-ink-4); }

/* One focus treatment for everything, MAGI's: a 2px ink outline, offset 2.
 * It is the same on a button, a link and a textarea, so it is learned once. */
:where(button, [role='button'], a, [tabindex], select, input, textarea,
       summary):focus-visible {
  outline: 2px solid var(--c-ink);
  outline-offset: 2px;
}

/* Primary action = MAGI's `solid` tone: paper type on the darkest ink there
 * is. Green was the old primary; it is now the brand mark and nothing else,
 * because a colour that means both "mirai" and "press this" means neither. */
button { padding: 8px 12px; font-size: 12px; font-weight: 500; line-height: 1;
         border: 1px solid var(--c-ink); font-family: inherit;
         border-radius: var(--r-sm); cursor: pointer;
         background: var(--c-ink); color: var(--c-paper);
         transition: transform 140ms var(--ease-out), background-color 90ms ease; }
button:disabled { pointer-events: none; opacity: 0.45; }
/* Press, not hover. MAGI lifts nothing on hover — a 1px rise under the cursor
 * is decoration; a 3% squash under the finger is the interface confirming it
 * heard you. */
button:active { transform: scale(0.97); }
button.ghost { background: var(--c-surface); color: var(--c-ink-2);
               border-color: var(--c-line-strong); }
button.ghost:hover { color: var(--c-ink); border-color: var(--c-ink-4); }
button.small { padding: 6px 9px; font-size: 11px; margin-top: 6px; }
button.big { padding: 10px 16px; font-size: 13px; border-radius: var(--r-sm); }
/* The one red thing on the page. MAGI's `danger`: tinted, bordered, never a
 * solid slab — a solid red button in a page of quiet ones is a dare. */
button.hang, button.dial.live {
  background: var(--c-alarm-bg); color: var(--c-alarm-ink);
  border-color: var(--c-alarm); font-weight: 600;
}
button.hang:hover, button.dial.live:hover { background: var(--c-alarm-bg-hover); }
/* The second way to place a call, and it reads as the second way: hairline,
 * not slab. `.dial.live` above outranks this on specificity, so hanging up a
 * dialled call is still the red control. */
button.dial { background: var(--c-surface); color: var(--c-ink-2);
              border-color: var(--c-line-strong); }
button.dial:hover { color: var(--c-ink); border-color: var(--c-ink-4); }
.chip { padding: 6px 10px; font-size: 12px; border: 1px solid var(--c-line-strong);
        border-radius: var(--r-sm); background: var(--c-surface);
        color: var(--c-ink-2); cursor: pointer; font-weight: 500; line-height: 1;
        transition: border-color 90ms ease, color 90ms ease; }
.chip:hover { border-color: var(--c-ink-4); color: var(--c-ink); }
.chip.on { background: var(--c-ink); border-color: var(--c-ink);
           color: var(--c-paper); }

/* ══ AGENT VIEW ════════════════════════════════════════════════════════════
 * ONE AGENT, ONE CARD. This was two cards — an agent bar, then "Your agent's
 * script" — with a hairline and 14px of nothing between the name of a thing
 * and the thing itself. Two cards is two objects and there is only one object
 * here, so the header names which agent and everything under the rule belongs
 * to it. See the note at the top of agent.html.
 *
 * The header is the only part about the SET of agents (switch, add, delete);
 * Save sits with them because it saves the whole card, and because a Save
 * button at the bottom of a 3,000-character script is a Save button nobody
 * finds. */
.agent-card { padding: 0; }
.agent-head { display: flex; align-items: center; justify-content: space-between;
              gap: 12px 16px; flex-wrap: wrap;
              padding: 13px 18px; border-bottom: 1px solid var(--c-line);
              background: var(--c-sunk); border-radius: var(--r-md) var(--r-md) 0 0; }
/* The agent strip. Replaces a <select> that hid the whole list behind a click —
 * see the note over .agent-tabs in agent.html. It scrolls sideways rather than
 * wrapping: a header that grows to three rows when a workspace reaches nine
 * agents pushes the script down the page every time somebody adds one.
 *
 * No scrollbar-hiding tricks. On the platforms that draw one it is the only
 * thing telling you there are more agents off the right-hand edge. */
.agent-tabs { display: flex; align-items: center; gap: 6px;
              min-width: 0; flex: 1 1 auto;
              overflow-x: auto; overscroll-behavior-x: contain;
              padding: 2px 0; }
.agent-tab { flex: 0 0 auto; max-width: 220px; overflow: hidden;
             text-overflow: ellipsis; white-space: nowrap; }
/* "+ New agent" is the last chip and reads as an empty slot rather than a
 * fourth agent: dashed edge, quiet ink, and it never takes the `on` fill. */
.agent-tab-new { flex: 0 0 auto; white-space: nowrap;
                 border-style: dashed; color: var(--c-ink-3); }
.agent-tab-new:hover { color: var(--c-ink); }
.agent-acts { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; }
.agent-acts button.ghost { background: var(--c-surface); }
/* Delete is a link, not a button: it is the one destructive action on the view
 * and it must not carry the same weight as "New agent". It turns alarm on
 * hover only — a permanently red word on a page you open every day is a
 * warning nobody reads by the third visit. */
.agent-del { background: none; border: 0; padding: 6px 2px; cursor: pointer;
             font-family: inherit; font-size: 11.5px; font-weight: 500;
             color: var(--c-ink-3); text-decoration: underline;
             text-decoration-style: dotted; text-underline-offset: 3px; }
.agent-del:hover { color: var(--c-alarm-ink); background: none; transform: none; }
.agent-del:disabled { pointer-events: none; opacity: 0.45; }

.agent-body { padding: 4px 18px 18px; }
/* The name IS the subject of the card, so it is set at heading weight. A 12px
 * box in a form row would have made the page's subject look like a setting. */
.agent-name { font-size: 16px; font-weight: 600; letter-spacing: -0.018em;
              padding: 9px 11px; width: 100%; max-width: 360px;
              color: var(--c-ink); }
.agent-nameblock { padding-top: 14px; }
.agent-sec { margin-top: 16px; padding-top: 14px;
             border-top: 1px solid var(--c-line); }

/* Opened by "New agent", inside the card, directly under the header it belongs
 * to — so the field appears where the button that summoned it is.
 *
 * Sunk, not amber. This shipped on --c-open-bg with --c-open-ink copy, which
 * broke rule 2: amber is a SEMANTIC in this system — "unresolved", something
 * the customer still has to deal with — and not a highlight for whatever the
 * page most wants you to look at. A create form is neither unresolved nor a
 * warning; it is a drawer that opened. Spend the same amber on a real open
 * state and it stops meaning anything. The recessed fill plus the hairline
 * under it says "this belongs to the header above" without borrowing a colour
 * that has a job. */
.agent-new { display: none; gap: 8px; align-items: center; flex-wrap: wrap;
             padding: 13px 18px; border-bottom: 1px solid var(--c-line);
             background: var(--c-sunk); }
.agent-new.on { display: flex; }
.agent-new input { flex: 1 1 220px; min-width: 0; background: var(--c-surface); }
.agent-new-hint { flex: 1 1 100%; margin: 0; font-size: 11.5px;
                  line-height: 1.55; color: var(--c-ink-3); }

/* ── the script ───────────────────────────────────────── */
.tools-label { display: block; margin-bottom: 6px; }
.script-tools { display: flex; gap: 6px; margin: 0 0 4px; flex-wrap: wrap; }
.script-tools select { min-width: 190px; }
.script-tools input { flex: 1; min-width: 210px; }

/* ── the starting point ────────────────────────────────────────────────────
 * A fold, not a permanent row — see the note over #startpoint in agent.html.
 * It borrows the .docs treatment rather than inventing a second disclosure:
 * this console has exactly one way a <details> looks, and a customer who has
 * opened "How this sandbox works" has already learned it. The difference is
 * that this one sits INSIDE the card it acts on, so it is sunk rather than
 * raised — it is part of the agent, not a note beside it. */
.startpoint { background: var(--c-sunk); border: 1px solid var(--c-line);
              border-radius: var(--r-md); padding: 0 14px;
              margin-top: 16px; }
.startpoint summary { cursor: pointer; font-size: 12.5px; font-weight: 500;
                      padding: 11px 0; color: var(--c-ink-2);
                      list-style-position: inside; }
.startpoint summary:hover { color: var(--c-ink); }
.startpoint[open] summary { border-bottom: 1px solid var(--c-line); }
.sp-body { padding: 12px 0 14px; }
.sp-row .script-tools { margin-bottom: 0; }
.sp-row input, .sp-row select { background: var(--c-surface); }
/* The rule-and-word divider from the rail, reused: two ways to do one thing,
 * with the word that says they are alternatives. */
.sp-body .rail-div { margin: 14px 0; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
           margin-top: 12px; }

/* ── the script editor ────────────────────────────────────────────────────
 * Customers paste two to four thousand characters of system prompt in here.
 * The box used to open at ten lines, which made a real script look like it had
 * been truncated. It now opens at seventeen, grows with the text to a
 * viewport-bound ceiling and scrolls past that, and the character count sits
 * in the label row so nobody has to guess whether the paste landed whole.
 * Growth is a height write on input, not an animation — the caret must not
 * move on a timing curve while you are typing at it. */
.editor { margin-top: 14px; }
.two-col .editor { margin-top: 0; }
.editor-head { display: flex; align-items: baseline; gap: 10px;
               justify-content: space-between; margin-bottom: 6px; }
.editor-head label { line-height: 1.3; }
.charcount { font-size: 10.5px; color: var(--c-ink-3); white-space: nowrap;
             font-variant-numeric: tabular-nums; }
.editor-body { display: block; width: 100%; }
/* A script is read by a machine and scanned by a human for structure, so the
 * long one gets the mono face and a looser leading. Devanagari falls back to
 * the system Indic face per glyph, which is correct — there is no monospaced
 * Devanagari worth forcing. */
textarea.editor-lg { font-family: var(--mono); font-size: 12.5px;
                     line-height: 1.65; min-height: 360px; tab-size: 2; }
textarea.editor-sm { min-height: 88px; line-height: 1.6; }
.docs { background: var(--c-surface); border: 1px solid var(--c-line);
        border-radius: var(--r-md); padding: 2px 18px 4px; }
.docs summary { cursor: pointer; font-size: 12.5px; font-weight: 500;
                padding: 11px 0; color: var(--c-ink-2); list-style-position: inside; }
.docs summary:hover { color: var(--c-ink); }
.docs ol { margin: 2px 0 12px; padding-left: 20px; color: var(--c-ink-2);
           font-size: 12px; line-height: 1.75; }

/* ── right rail · test ────────────────────────────────── */
.rail { position: sticky; top: 16px; height: calc(100vh - 32px);
        height: calc(100dvh - 32px);
        display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.rail-head { display: flex; align-items: baseline; justify-content: space-between;
             gap: 10px; padding: 2px 2px 0; flex-wrap: wrap; }
.rail-head h2 { font-weight: 600; font-size: 13px; letter-spacing: -0.01em;
                margin: 0; color: var(--c-ink); }
.statusline { font-size: 11px; color: var(--c-ink-3); padding: 0 2px;
              min-height: 15px; }
.rail-card { background: var(--c-surface); border: 1px solid var(--c-line);
             border-radius: var(--r-md); padding: 12px 14px; }
/* The tier picker that used to share this row is gone, and so is the voice
 * dropdown that replaced it — both were controls with nothing to choose (see
 * the comments in index.html). What is left states what you are about to hear
 * and then gives you one button to hear it: label left, value right, one
 * hairline between the statement and the action. */
/* A labelled control in the rail. The label uses the same eyebrow treatment as
 * .rail-fact-k so a picker and a stated fact read as the same kind of thing. */
.rail-field { display: flex; align-items: center; justify-content: space-between;
              gap: 10px; padding: 7px 0; }
/* 66, not 60: agent names are the longest values these pickers ever hold — a
 * customer types "EMI reminder — Hindi" and the label beside it is one short
 * word, so the six points come off whitespace nothing was using. */
.rail-field select { flex: 1 1 auto; min-width: 0; max-width: 66%; }

/* ── the tier strip ────────────────────────────────────────────────────────
 * Reuses .howrow/.howtab (the call-method switch) because it is the same kind
 * of control and a customer should only have to learn one: a single hairline
 * box split into equal parts, the selected one carrying weight and ink.
 * Three columns rather than two, and one word in each: the tier's name. */
/* First control in the card, so it gets the card's top edge and a rule under
 * it — everything below is a setting WITHIN the tier, and the hairline says so. */
.tierpick { padding: 0 0 12px; margin-bottom: 4px;
            border-bottom: 1px solid var(--c-line); }
/* .howrow.tierrow, not .tierrow: the base .howrow below is a TWO-column switch
 * and it is declared later in this file, so at equal specificity it won it —
 * which is why T5 spent a release wrapped onto a second row. The doubled class
 * is the whole fix; do not "simplify" it back. */
.howrow.tierrow { grid-template-columns: repeat(3, 1fr); margin: 0; }
/* One word per tab, centred, at a size that can carry a tab on its own now
 * that it is not sharing the line with a price. */
.tierrow .howtab { min-height: 42px; padding: 8px 4px; line-height: 1.2;
                   white-space: nowrap; }
.tierrow .howtab b { font-size: 13px; font-weight: 600; letter-spacing: 0.01em; }
/* A tier this deployment cannot serve. It has to READ as unreachable and still
 * take a press — agent.js shows its sentence instead of selecting it — so it
 * keeps the disabled treatment and loses `cursor: not-allowed`, which promises
 * that nothing will happen. */
.tierrow .howtab[aria-disabled="true"] { opacity: 0.5; }
/* The description of whatever is selected. It is the panel the strip is
 * joined to, so it carries the same paper and no top rule — the strip and its
 * sentence are one object, the way the call-method switch and its panel are. */
.tiernote { margin: 0; padding: 8px 10px; font-size: 11.5px; line-height: 1.45;
            color: var(--c-ink-3); background: var(--c-surface);
            border: 1px solid var(--c-line); border-top: 0;
            border-radius: 0 0 var(--r-sm) var(--r-sm); }
.tiernote b { color: var(--c-ink-2); font-weight: 600; }

/* ── the intelligence moment ───────────────────────────────────────────────
 * Moving up a tier is the one place in this console where something genuinely
 * changes about what the agent can do, and it happens a handful of times per
 * session — rare enough to afford delight, unlike the call button. So the
 * strip gets three coordinated things, all transform/opacity, all under the
 * 300ms rule for the parts that carry meaning:
 *
 *   1. a sheen that sweeps the strip once   (the state changed)
 *   2. sparks around the tab you chose      (what it changed TO)
 *   3. the sentence below crossfades up     (what that means)
 *
 * Sparks fire only when moving UP. Motion that fires identically in both
 * directions is decoration; motion that fires in one direction is a sentence.
 * Going back down to t1 gets the sweep and the crossfade and nothing else —
 * which is the honest reading, because nothing got smarter.
 *
 * The crossfade carries a 2px blur for the 90ms it overlaps: without it you
 * see two sentences stacked rather than one becoming the other. */
.tierpick { position: relative; }
.tierrow { position: relative; isolation: isolate; }
/* The sheen. A single pass of a soft highlight across the strip, clipped to
 * it, behind the labels — a light moving under glass, not a flash. */
.tierrow::after {
  content: ""; position: absolute; inset: 0; z-index: -1; opacity: 0;
  /* LIGHT, not hue. The one brand colour in this build means "mirai minds",
   * never "primary" and never "good" (see the palette note), so the sweep is
   * a highlight passing over paper — the same move a sheen makes on metal. */
  background: linear-gradient(100deg, transparent 20%,
              rgba(255, 255, 255, 0.72) 48%, transparent 76%);
  transform: translateX(-60%);
}
.tierrow.sweep::after { animation: tier-sweep 760ms var(--ease-out) both; }
@keyframes tier-sweep {
  0%   { opacity: 0;   transform: translateX(-70%) skewX(-8deg); }
  18%  { opacity: 1; }
  100% { opacity: 0;   transform: translateX(70%)  skewX(-8deg); }
}
/* The tab you landed on reacts to being landed on: a fast lift and settle.
 * It is the only element that MOVED in the interaction, so it is the one that
 * should acknowledge the press — and it is transform-only, so the strip's
 * layout never shifts under it. */
.tierrow .howtab.landed { animation: tier-land 420ms var(--ease-out) both; }
@keyframes tier-land {
  0%   { transform: scale(0.97); }
  38%  { transform: scale(1.055); }
  100% { transform: scale(1); }
}
/* …and a ring that expands out of it once and dissolves — the visual echo of
 * something switching on. Drawn on the strip, not on the button, so it can
 * overflow the tab without being clipped by it. */
.tierglow { position: absolute; left: var(--sx, 0px); width: var(--sw, 0px);
            top: 0; height: 100%; pointer-events: none; z-index: 1; opacity: 0;
            border-radius: var(--r-sm);
            box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.9) inset; }
.tierglow.on { animation: tier-glow 640ms var(--ease-out) both; }
@keyframes tier-glow {
  0%   { opacity: 0;    transform: scale(0.94); }
  30%  { opacity: 0.95; transform: scale(1.02);
         box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.95) inset,
                     0 0 14px 2px rgba(255, 255, 255, 0.55); }
  100% { opacity: 0;    transform: scale(1.09);
         box-shadow: 0 0 0 6px rgba(255, 255, 255, 0) inset,
                     0 0 22px 6px rgba(255, 255, 255, 0); }
}
/* The sparks. Positioned over the selected tab by renderTiers(), which sets
 * --sx/--sw from its offsets — four points of light, staggered, each rising a
 * few pixels as it fades. Never from scale(0): they start at 0.4, so they read
 * as something small getting brighter rather than something from nowhere. */
/* The sparks overflow the tab deliberately — they are what the tier change
 * throws off, and a spark that stops at the button edge reads as a texture
 * rather than an event. -8px/16px of headroom on each side. */
.tiersparks { position: absolute; left: calc(var(--sx, 0px) - 8px);
              width: calc(var(--sw, 0px) + 16px);
              top: -8px; height: 56px; pointer-events: none; z-index: 2; }
.tiersparks i { position: absolute; left: var(--x); top: var(--y);
                width: var(--s, 9px); height: var(--s, 9px); opacity: 0;
                /* ink, for the same reason the sweep is light: a coloured
                 * spark would be the palette's one brand hue used as applause */
                background: var(--c-ink-2);
                /* a four-point star, so it reads as a spark and not a dot */
                clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%,
                                   50% 100%, 40% 60%, 0 50%, 40% 40%); }
.tiersparks.on i { animation: spark 720ms var(--ease-out) var(--d, 0ms) both; }
/* Rise, spin a quarter turn, and go. The rotation is what makes a spark read
 * as light rather than as a small black star sliding upwards. */
@keyframes spark {
  0%   { opacity: 0; transform: scale(0.4) translateY(4px)   rotate(-25deg); }
  30%  { opacity: 1; transform: scale(1.25) translateY(-2px) rotate(10deg); }
  60%  { opacity: 0.85; transform: scale(0.95) translateY(-8px) rotate(45deg); }
  100% { opacity: 0; transform: scale(0.5) translateY(-16px) rotate(70deg); }
}
.tiernote { transition: opacity 140ms var(--ease-out),
                        transform 140ms var(--ease-out),
                        filter 140ms var(--ease-out); }
.tiernote.swapping { opacity: 0; transform: translateY(3px); filter: blur(2px); }

/* Reduced motion keeps the meaning and drops the movement: the sentence still
 * crossfades so you can see it changed, the sweep and the sparks do not run. */
@media (prefers-reduced-motion: reduce) {
  .tierrow.sweep::after { animation: none; }
  .tiersparks.on i { animation: none; }
  .tierglow.on { animation: none; }
  .tierrow .howtab.landed { animation: none; }
  .tiernote.swapping { transform: none; filter: none; }
}

.rail-fact { display: flex; align-items: baseline; justify-content: space-between;
             gap: 10px; padding-bottom: 10px; margin-bottom: 12px;
             border-bottom: 1px solid var(--c-line); }
.rail-fact-v { font-size: 12.5px; font-weight: 600; color: var(--c-ink);
               text-align: right; min-width: 0; }

/* THE PRIMARY ACTION. There is exactly one solid full-width control in the
 * console and this is it — dialling a phone sits below a divider in the ghost
 * treatment, because two equally-weighted dark buttons side by side is the
 * same as having none. */
.rail-cta { display: flex; flex-direction: column; gap: 8px; }
#btn { width: 100%; padding: 12px 14px; font-size: 13.5px; font-weight: 600;
       letter-spacing: -0.005em;
       display: flex; align-items: center; justify-content: center; gap: 9px; }

/* ── the sleeping cat ──────────────────────────────────────────────────────
 * The GPU fleet being asleep is a fact about THIS BUTTON — press it and the
 * agent connects, thinks, says "speaking", and makes no sound — and it was
 * being reported by an amber banner three feet away at the top of the page.
 * So the state lives on the control it is about.
 *
 * It is a joke because the alternative is a customer thinking the product is
 * broken. A cat asleep on the button, and a hint under it naming the hours,
 * says "come back at ten" in the time it takes to glance at it; a red error
 * says "this does not work". The fleet is on a schedule, not on fire.
 *
 * The button STAYS PRESSABLE. Disabling it would be the console deciding for
 * someone whether their call is worth trying, and the t3 voice does not need
 * the GPU at all — same reasoning as the tier picker in agent.js.
 *
 * Inline SVG, not a GIF: no request, no fixed raster size, it inherits
 * currentColor so it works on the dark button, on the amber sleep state and in
 * the phone dock, and it can honour prefers-reduced-motion — which a GIF, by
 * construction, cannot. */
/* --cat-cut is what the eyes and muzzle are cut out IN: the button's own fill,
 * so the features are holes in the silhouette rather than darker paint on dark
 * paint. Declared here (and inherited by the SVG) so the one place that knows
 * what colour the sleeping button is stays the one place that sets it. */
.btn-cat { display: none; width: 32px; height: 20px; flex: 0 0 auto;
           --cat-cut: var(--c-open-bg); }
#btn.asleep .btn-cat { display: block; }
/* Amber = `open`/unresolved, the same semantic as the banner and the latency
 * chart's middle band. Never alarm red: nothing has broken. */
#btn.asleep { background: var(--c-open-bg); color: var(--c-open-ink);
              border-color: var(--c-open-line); }
#btn.asleep:hover { background: var(--c-open-bg); }
/* THE ONE PIECE OF AMBIENT MOTION IN THE BUILD BESIDES #speaking, and it earns
 * the exception the same way: it exists only while a specific state is true,
 * it is the thing that communicates that state, and it stops the moment the
 * fleet answers. Slow enough to read as breathing rather than as a spinner. */
@keyframes cat-breathe { 0%, 100% { transform: scaleY(1); }
                         50% { transform: scaleY(1.06); } }
@keyframes cat-z { 0%   { opacity: 0; transform: translate(0, 3px); }
                   30%  { opacity: 1; }
                   100% { opacity: 0; transform: translate(3px, -5px); } }
#btn.asleep .cat-body { transform-origin: 50% 100%;
                        animation: cat-breathe 3.4s ease-in-out infinite; }
#btn.asleep .cat-z text { animation: cat-z 3.4s ease-in-out infinite; }
#btn.asleep .cat-z .z2 { animation-delay: 0.5s; }
#btn.asleep .cat-z .z3 { animation-delay: 1s; }

/* ── the cat that waits with you ───────────────────────────────────────────
 * At capacity a call joins a global FIFO and this button becomes "In queue ·
 * #3 — tap to leave". The wait is real, it is nobody's fault and it ends by
 * itself, so the cat sits down for it.
 *
 * NEUTRAL INK, NOT AMBER, and that is the whole design decision here. Amber in
 * this system means unresolved — something you are going to have to deal with.
 * A queue is not that. It is the product working correctly while busy, so the
 * button keeps its ordinary fill and the cut-outs are cut in that fill.
 *
 * .asleep beats .queued if both are somehow true: a fleet that cannot speak is
 * a stronger fact about pressing this button than a place in a line for it. */
.btn-cat-wait { display: none; width: 32px; height: 20px; flex: 0 0 auto;
                --cat-cut: var(--c-ink); }
#btn.queued .btn-cat-wait { display: block; }
#btn.asleep .btn-cat-wait { display: none; }
/* Same licence as the sleeping cat's breathing: it exists only while the state
 * is true, it IS the state, and it stops the instant the queue does. A few
 * degrees, hinged where the tail meets the rump — a tail tip moving 3° over
 * two and a half seconds is a cat waiting, not a spinner. transform-box is
 * view-box for SVG, so the origin is in the drawing's own coordinates. */
@keyframes cat-tail { 0%, 100% { transform: rotate(0deg); }
                      45%      { transform: rotate(-3.5deg); }
                      70%      { transform: rotate(1.5deg); } }
#btn.queued .cat-tail { transform-origin: 28.8px 24.4px;
                        animation: cat-tail 2.5s ease-in-out infinite; }

/* ── the cat gets up ───────────────────────────────────────────────────────
 * One shot on a real event — the fleet came back while somebody was looking.
 * Inserted by _gpuPaintButton in index.html and removed 1.6s later, so there is
 * nothing here to stop: the element leaves. `both` holds the last frame for the
 * few milliseconds between the animation ending and the node going. */
.btn-cat-stretch { width: 32px; height: 20px; flex: 0 0 auto;
                   --cat-cut: var(--c-ink);
                   animation: cat-stretch 1.6s var(--ease-out) both; }
/* ONE CAT AT A TIME, from the other side. _catStretch refuses to insert one
 * while the button is queued, but the reverse race is real and one second and a
 * half wide: the fleet wakes, the stretch goes in, the customer presses the
 * button straight away and is put in the queue — leaving a cat stretching next
 * to a cat sitting. Whatever the button's CURRENT state is outranks a leftover
 * of the state before it. */
#btn.queued .btn-cat-stretch, #btn.asleep .btn-cat-stretch { display: none; }
@keyframes cat-stretch {
  0%   { opacity: 0; transform: scaleY(0.78) translateY(2px); }
  22%  { opacity: 1; }
  55%  { transform: scaleY(1.08) translateY(-1.5px); }
  100% { opacity: 1; transform: scaleY(1) translateY(0); } }

/* ── the cat that listens ──────────────────────────────────────────────────
 * The two empty states: a transcript with no call in it yet, and a history with
 * no calls in it yet. Both are the same sentence in different words — nothing
 * has happened, and something is ready for it to.
 *
 * STATIC. No breathing, no flick, nothing. The other two poses animate because
 * they are reporting a live state that is currently changing; an empty list is
 * not changing, and a cat idling on it would be the decoration this family is
 * not allowed to be. --c-ink-4 is the decoration ramp, which is exactly what
 * this is: furniture beside a sentence, never a thing to read. */
.cat-still { flex: 0 0 auto; color: var(--c-ink-4);
             --cat-cut: var(--c-surface); }
.sys.hint { display: flex; align-items: center; justify-content: center;
            gap: 9px; text-align: left; }
.sys.hint .cat-still { width: 40px; height: 25px; }
.hist-empty { display: flex; align-items: center; gap: 11px; }
.hist-empty .cat-still { width: 34px; height: 21px; }
/* The hint under the button becomes the schedule while the fleet is down. */
.rail-hint.asleep { color: var(--c-open-ink); }
.rail-cta #meter { flex: 0 0 auto; width: 100%; }
.rail-hint { margin: 0; font-size: 11px; line-height: 1.5; color: var(--c-ink-3); }
.rail-div { display: flex; align-items: center; gap: 10px; margin: 12px 0;
            color: var(--c-ink-3); font-size: 10.5px; }
.rail-div::before, .rail-div::after { content: ''; flex: 1; height: 1px;
                                      background: var(--c-line); }
/* ── how to call ───────────────────────────────────────────────────────────
 * Two ways, both on screen. This replaced a <details> whose arrow you had to
 * find before the product's second capability existed for you.
 *
 * It is a switch, so it is drawn as one surface split in two, not as two
 * buttons: a single hairline box with a hairline between the halves. The
 * selected half is the paper the panel below is drawn on — same fill, no
 * bottom rule — so the switch and its panel read as one object. Rule 2:
 * selection carries weight AND ink AND the joined edge, never hue alone. */
.howrow { display: grid; grid-template-columns: 1fr 1fr; margin: 12px 0 0;
          border: 1px solid var(--c-line); border-radius: var(--r-sm);
          background: var(--c-sunk); overflow: hidden; }
.howtab { display: flex; align-items: center; justify-content: center; gap: 7px;
          min-height: 38px; padding: 8px 6px; border: 0; background: none;
          border-radius: 0; cursor: pointer; font-family: inherit;
          font-size: 11.5px; font-weight: 500; color: var(--c-ink-3);
          letter-spacing: 0.01em; }
.howtab svg { width: 15px; height: 15px; flex: 0 0 auto; opacity: 0.85; }
.howtab + .howtab { border-left: 1px solid var(--c-line); }
.howtab.on { background: var(--c-surface); color: var(--c-ink); font-weight: 600; }
.howtab.on svg { opacity: 1; stroke-width: 2; }
.howtab:disabled { cursor: not-allowed; opacity: 0.5; }
@media (hover: hover) and (pointer: fine) {
  /* aria-disabled as well as :disabled — the tier strip marks the tier it
     cannot serve the first way (it still takes a press) and a hover that
     brightens it would read as an offer. */
  .howtab:not(.on):not(:disabled):not([aria-disabled="true"]):hover {
    color: var(--c-ink-2); }
}
/* The panel is the same paper as the selected half, joined to it. */
#cta-web, #dialbody { border: 1px solid var(--c-line); border-top: 0;
                      border-radius: 0 0 var(--r-sm) var(--r-sm);
                      background: var(--c-surface); padding: 12px; }
#cta-web[hidden], #dialbody[hidden] { display: none; }
.dialwrap .rail-hint { margin-top: 8px; }
/* MAGI's disclosure trigger: a dotted underline, the oldest "there is more
 * here" affordance there is. Ink, not a link colour — there is no link colour. */
.linkbtn { background: none; border: none; color: var(--c-ink-2);
           font-size: 11.5px; font-weight: 500; padding: 8px 0 0; cursor: pointer;
           text-decoration: underline; text-decoration-style: dotted;
           text-underline-offset: 3px; }
.linkbtn:hover { color: var(--c-ink); background: none; transform: none; }
.action-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
input#phone { flex: 1; min-width: 130px; }
#meter { flex: 1; min-width: 60px; height: 4px; background: var(--c-line);
         border-radius: var(--r-xs); overflow: hidden; }
#meter i { display: block; height: 100%; width: 0%; background: var(--c-ink-2);
           transition: width 80ms linear; }

.pane { background: var(--c-surface); border: 1px solid var(--c-line);
        border-radius: var(--r-md); display: flex; flex-direction: column;
        min-height: 0; }
#chatpane { flex: 1; }
#chatpane h2 { margin: 0; padding: 11px 14px 8px; }
#chat { flex: 1; overflow-y: auto; padding: 2px 12px 12px; display: flex;
        flex-direction: column; gap: 8px;
        scrollbar-width: thin; scrollbar-color: var(--c-line-strong) transparent; }
/* Speaker is carried by ALIGNMENT and FILL, not by hue: the bot sits left in
 * the sunk fill, you sit right on surface behind a hairline. It survives
 * greyscale, which a green-tinted "you" bubble does not. */
.msg { max-width: 88%; padding: 8px 10px; border-radius: var(--r-md);
       font-size: 12.5px; line-height: 1.55; white-space: pre-wrap;
       animation: msg-in 200ms var(--ease-out) both; }
.msg .who { display: block; font-size: 10px; color: var(--c-ink-3);
            margin-bottom: 3px; letter-spacing: 0.04em; }
.bot  { align-self: flex-start; background: var(--c-sunk);
        border: 1px solid var(--c-line); border-bottom-left-radius: var(--r-xs);
        color: var(--c-ink); }
.user { align-self: flex-end; background: var(--c-surface);
        border: 1px solid var(--c-line-strong);
        border-bottom-right-radius: var(--r-xs); color: var(--c-ink); }
.sys  { align-self: center; color: var(--c-ink-3); font-size: 11px; padding: 2px 0;
        text-align: center; animation: fade-in 200ms var(--ease-out) both; }
.sys a { color: var(--c-ink-2); font-weight: 500;
         text-decoration: underline; text-decoration-style: dotted;
         text-underline-offset: 3px; }
.sys a:hover { color: var(--c-ink); }
.sys.hint { padding: 12px 0; }
#speaking { align-self: flex-start; color: var(--c-ink-2); font-size: 11.5px;
            padding: 0 2px; display: none; }
/* THE ONE LOOPING ANIMATION IN THE BUILD, and it is allowed because it is not
 * ambient: it exists only while audio is actually playing and it is the only
 * thing on screen that says so. It stops the instant the turn ends. */
#speaking::after { content: '●●●'; letter-spacing: 3px; margin-left: 7px;
                   font-size: 7px; vertical-align: 2px;
                   animation: pulse 1.4s ease-in-out infinite; }

/* ── response speed · the turn chart ──────────────────────────────────────
 * This was a six-column table of milliseconds. It is now one bar per turn,
 * drawn to a shared scale, because "1240" and "980" are two numbers a customer
 * has to compare by reading, while two bars against the same 1 s rule are one
 * glance. The bar is the reply — you stop talking → the agent starts — split
 * into the three stages that produced it, in pipeline order, left to right.
 *
 * RULE 2, all three axes:
 *   LENGTH is the primary encoding. Every bar crosses the same two vertical
 *     rules (1 s ink, 2 s alarm), so the verdict is a position, readable with
 *     no colour vision at all and no legend.
 *   SHAPE is second: ○ a hollow bar in the quiet neutral ramp under a second,
 *     ▨ the amber hatch between one and two, ■ a solid alarm slab over two —
 *     the same three marks the rest of the console uses, and the figure at the
 *     end of the row carries the matching swatch.
 *   HUE is last and always redundant.
 * Under 1 s stays deliberately quiet: a panel that celebrates every turn is a
 * panel nobody reads on the turn that matters.
 *
 * The stage divisions survive all three states as paper hairlines, so a slow
 * turn still shows you WHICH stage ate the second. Stage times are measured
 * independently and overlap slightly; the segments are their shares of the
 * reply, not a claim that they sum to it. */
.latwrap { padding: 0 0 4px; max-height: 274px; overflow: hidden;
           display: flex; flex-direction: column; }
.latwrap summary { cursor: pointer; padding: 11px 14px 8px;
                   list-style-position: inside; }
/* one definition of the row grid + the two threshold positions; call.js writes
 * --t1 / --t2 here and the axis and every bar inherit them */
#latpane { --lat-lead: 52px; --lat-tail: 58px; --t1: 40%; --t2: 80%; }

.lat-sum { display: flex; align-items: baseline; gap: 8px; padding: 0 14px 9px; }
.lat-sum:empty { display: none; }
.lat-sum-fig { font-size: 19px; font-weight: 600; letter-spacing: -0.025em;
               line-height: 1; color: var(--c-ink);
               font-variant-numeric: tabular-nums; }
.lat-sum-note { font-size: 11px; color: var(--c-ink-3); }

.lat-axis, .lat-row {
  display: grid; align-items: center; gap: 9px;
  grid-template-columns: var(--lat-lead) minmax(0, 1fr) var(--lat-tail);
}
.lat-axis { display: none; padding: 0 14px 3px; align-items: end; }
#latpane.has-data .lat-axis { display: grid; }
.lat-axis-lab { font-size: 9.5px; letter-spacing: 0.06em; line-height: 1;
                text-transform: uppercase; color: var(--c-ink-3); }
.lat-scale { position: relative; height: 11px; }
.lat-scale i { position: absolute; top: 0; transform: translateX(-50%);
               font-style: normal; font-size: 9.5px; line-height: 1;
               color: var(--c-ink-3); font-variant-numeric: tabular-nums; }
.lat-scale i.t1 { left: var(--t1); }
.lat-scale i.t2 { left: var(--t2); }

#lat { overflow: auto; padding: 0 14px 8px;
       scrollbar-width: thin; scrollbar-color: var(--c-line-strong) transparent; }
.lat-row { padding: 3px 0; }
@media (hover: hover) and (pointer: fine) {
  .lat-row { transition: background-color 90ms ease; }
  .lat-row:hover { background: var(--c-sunk); }
}
.lat-n { font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase;
         font-weight: 600; color: var(--c-ink-3); white-space: nowrap;
         overflow: hidden; text-overflow: ellipsis;
         font-variant-numeric: tabular-nums; }
.lat-track { position: relative; height: 13px; background: var(--c-sunk);
             border-radius: var(--r-xs); }
.lat-bar { position: absolute; top: 0; bottom: 0; left: 0; display: flex;
           min-width: 4px; overflow: hidden; border-radius: var(--r-xs);
           border: 1px solid var(--c-ink-4); background: var(--c-surface); }
.lat-seg { display: block; height: 100%; min-width: 0; }
.lat-seg + .lat-seg { border-left: 1px solid var(--c-surface); }
/* stage identity is LIGHTNESS on the neutral ink ramp — no hue at all, so it
 * is legible to everyone and it never competes with the verdict */
.g-stt { background: var(--c-line); }
.g-llm { background: var(--c-line-strong); }
.g-tts { background: var(--c-ink-4); }
.g-all { background: var(--c-line-strong); }
.lat-bar.warn { border-color: var(--c-open-line); }
.lat-bar.warn .lat-seg {
  background-color: var(--c-open-bg);
  background-image: repeating-linear-gradient(45deg,
    var(--c-open-hatch) 0 3px, transparent 3px 7px); }
.lat-bar.bad { border-color: var(--c-alarm); }
.lat-bar.bad .lat-seg { background: var(--c-alarm); }
/* the thresholds cross the bar rather than sitting behind it — which side of
 * the 1 s rule a bar ends on is the whole reading */
.lat-ticks { position: absolute; top: -2px; bottom: -2px; left: 0; right: 0;
             pointer-events: none;
             background-image: linear-gradient(var(--c-ink-4), var(--c-ink-4)),
                               linear-gradient(var(--c-alarm-line), var(--c-alarm-line));
             background-size: 1px 100%, 1px 100%;
             background-position: var(--t1) 0, var(--t2) 0;
             background-repeat: no-repeat; }

/* The figure at the end of the row. `good` / `warn` / `bad` come from cls() in
 * core.js — the names are load-bearing, the treatment is not. */
.lat-v { text-align: right; font-size: 11px; color: var(--c-ink-2);
         white-space: nowrap; font-variant-numeric: tabular-nums; }
.lat-v.warn, .lat-sum-fig.warn { color: var(--c-open-ink); font-weight: 500; }
.lat-v.bad,  .lat-sum-fig.bad  { color: var(--c-alarm-ink); font-weight: 600; }
.lat-v.warn::before, .lat-v.bad::before,
.lat-sum-fig.warn::before, .lat-sum-fig.bad::before {
  content: ''; display: inline-block; width: 7px; height: 7px;
  margin-right: 5px; vertical-align: 0; border: 1px solid;
}
.lat-v.warn::before, .lat-sum-fig.warn::before {
  border-color: var(--c-open-line); border-radius: 1px;
  background-color: var(--c-open-bg);
  background-image: repeating-linear-gradient(45deg,
    var(--c-open-hatch) 0 3px, transparent 3px 7px); }
.lat-v.bad::before, .lat-sum-fig.bad::before {
  border-color: var(--c-alarm); border-radius: 1px; background: var(--c-alarm); }
.lat-empty { color: var(--c-ink-3); font-size: 11.5px; line-height: 1.6;
             padding: 2px 0 10px; }

.legend { padding: 4px 14px 10px; font-size: 10.5px; color: var(--c-ink-3);
          line-height: 1.9; }
.lat-key { display: inline-flex; align-items: center; margin-right: 11px; }
.lat-key i { width: 15px; height: 8px; margin-right: 5px; border-radius: 1px;
             border: 1px solid var(--c-line-strong); }
.legend .good, .legend .warn, .legend .bad {
  display: inline-flex; align-items: center; white-space: nowrap;
}
.legend .good { color: var(--c-ink-2); }
.legend .warn { color: var(--c-open-ink); font-weight: 500; }
.legend .bad  { color: var(--c-alarm-ink); font-weight: 600; }
.legend .good::before, .legend .warn::before, .legend .bad::before {
  content: ''; display: inline-block; width: 8px; height: 8px;
  margin-right: 5px; border: 1px solid;
}
.legend .good::before { border-color: var(--c-ink-4); border-radius: 50%; }
.legend .warn::before { border-color: var(--c-open-line); border-radius: 1px;
                        background-color: var(--c-open-bg);
                        background-image: repeating-linear-gradient(45deg,
                          var(--c-open-hatch) 0 3px, transparent 3px 7px); }
.legend .bad::before  { border-color: var(--c-alarm); border-radius: 1px;
                        background: var(--c-alarm); }

/* ── home view ─────────────────────────────────────────────────────────────
 * A checklist, not three paragraphs of how-it-works — see the note at the top
 * of home.html. The cards became live, so they grew two things: a figure
 * (the workspace's own count) and, on the first one, the way in.
 *
 * The dark founder panel that used to sit under them is gone from this page
 * and its CSS with it; .founder-kicker and .founder-sign survive because the
 * sign-in gate still sets the note in them. */
.home-steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.how { background: var(--c-surface); border: 1px solid var(--c-line);
       border-radius: var(--r-md); padding: 14px 16px;
       display: flex; flex-direction: column; align-items: flex-start; }
/* The badge is a fixed square so the copy under it does not shift by a pixel
 * when "01" becomes "✓" — a checklist that reflows as you tick it is a
 * checklist that looks broken. */
.how .n { display: inline-flex; align-items: center; justify-content: center;
          min-width: 18px; height: 16px;
          font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
          color: var(--c-ink-3); font-variant-numeric: tabular-nums; }
.how b { display: block; font-size: 13px; font-weight: 600;
         letter-spacing: -0.01em; margin: 6px 0 4px; }
.how p { margin: 0; font-size: 12px; color: var(--c-ink-2); line-height: 1.6; }
/* Pushed to the bottom edge so three cards of unequal copy still line their
 * figures up — the numbers are meant to be read across, not hunted for. */
.how-fig { margin-top: auto; padding-top: 10px;
           font-size: 11.5px; font-weight: 600; color: var(--c-ink);
           font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
.how-act { margin: 10px 0 0; padding: 0; background: none; border: 0;
           font-family: inherit; font-size: 11.5px; font-weight: 500;
           color: var(--c-ink-2); cursor: pointer; text-decoration: underline;
           text-decoration-style: dotted; text-underline-offset: 3px; }
.how-act:hover { color: var(--c-ink); background: none; transform: none; }
/* DONE IS TOLD THREE WAYS, never by hue alone (rule 2): the glyph changes from
 * a numeral to a tick, the weight goes up, and only then does the colour move
 * to the accent. Read with the colour stripped out — by a colour-blind eye, or
 * printed — a bold ✓ still says done next to a light 02. */
.how.done { border-color: var(--accent-line); }
.how.done .n { color: var(--accent); font-weight: 700; font-size: 12px; }
.how.done .how-fig { color: var(--accent); }

/* The last call, as one line. Not a second history table: everything a person
 * would do with it is on the page it links to, and the only job here is to say
 * that something happened and to be the door. */
.home-recent { margin-top: 16px; background: var(--c-surface);
               border: 1px solid var(--c-line); border-radius: var(--r-md);
               padding: 12px 14px; }
.home-recent[hidden] { display: none; }
.home-recent-head { display: flex; align-items: baseline;
                    justify-content: space-between; gap: 10px; }
.home-recent-link { padding: 0; background: none; border: 0;
                    font-family: inherit; font-size: 11.5px; font-weight: 500;
                    color: var(--c-ink-2); cursor: pointer;
                    text-decoration: underline; text-decoration-style: dotted;
                    text-underline-offset: 3px; white-space: nowrap; }
.home-recent-link:hover { color: var(--c-ink); background: none; transform: none; }
/* The values are separated by the row's own gap rather than by punctuation —
 * four spans, one line, nothing between them to align or to translate. */
.home-recent-row { display: flex; align-items: baseline; gap: 10px;
                   flex-wrap: wrap; margin-top: 7px; font-size: 12px; }
.hr-kind { font-weight: 600; color: var(--c-ink); }
.hr-agent, .hr-dur, .hr-when { color: var(--c-ink-3); font-size: 11.5px;
                               min-width: 0; overflow-wrap: anywhere; }
.hr-when { margin-left: auto; font-variant-numeric: tabular-nums; }

.home-ask { margin: 16px 0 0; font-size: 12px; line-height: 1.7;
            color: var(--c-ink-3); }
.home-ask a { color: var(--c-ink-2); font-weight: 500;
              text-decoration: underline; text-decoration-style: dotted;
              text-underline-offset: 3px; }
.home-ask a:hover { color: var(--c-ink); }

/* ── history view ─────────────────────────────────────── */
.hist-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
              margin-bottom: 14px; }
.hist-stat { background: var(--c-surface); border: 1px solid var(--c-line);
             border-radius: var(--r-md); padding: 12px 14px; display: flex;
             flex-direction: column-reverse; gap: 6px; align-items: flex-start; }
/* MAGI's hero-figure treatment: the largest type on the block, tight tracking,
 * tabular, and NOT coloured. The label sits above it as an eyebrow. */
.hist-stat strong { font-size: 24px; font-weight: 600; letter-spacing: -0.025em;
                    line-height: 0.95; color: var(--c-ink); }
/* ── a history row ─────────────────────────────────────────────────────────
 * One circular control on the left and two lines of text beside it. It used to
 * be two dotted text links at the end of the second line — see the note over
 * the row's markup in views.js for what those did.
 *
 * The circle is deliberately the SAME object as .sc-play on the sign-in page:
 * 34px, one hairline, a triangle that inverts to a fill when it is the thing
 * currently open. A customer meets it before they have an account, playing a
 * sample; meeting it again here should cost them nothing to learn. */
.hist-row { display: grid; grid-template-columns: auto minmax(0, 1fr);
            align-items: center; gap: 0 12px; padding: 10px 4px;
            border-bottom: 1px solid var(--c-line); font-size: 12px;
            cursor: pointer; transition: background-color 90ms ease; }
.hist-lines { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
@media (hover: hover) and (pointer: fine) {
  .hist-row:hover { background: var(--c-sunk); }
}
.hist-row:last-child { border-bottom: none; }
/* The open row keeps the sunk fill so the player below it reads as belonging to
 * this entry rather than floating between two of them. Its hairline goes,
 * because the row and its player are one object while it is open. */
.hist-row.open { background: var(--c-sunk); border-bottom-color: transparent; }
.hist-play { flex: none; width: 34px; height: 34px; border-radius: 50%;
             padding: 0; display: flex; align-items: center;
             justify-content: center;
             border: 1px solid var(--c-line-strong); background: var(--c-surface);
             color: var(--c-ink); }
.hist-play svg { width: 13px; height: 13px; }
.hist-play .ico-pause { display: none; }
.hist-play:hover { border-color: var(--c-ink-4); }
.hist-play.on { border-color: var(--c-ink); background: var(--c-ink);
                color: var(--c-paper); }
.hist-play.on:hover { border-color: var(--c-ink); background: var(--c-ink); }
.hist-play.on .ico-play { display: none; }
.hist-play.on .ico-pause { display: block; }
.hist-top { display: flex; justify-content: space-between; align-items: baseline;
            gap: 8px; }
.hist-when { color: var(--c-ink-3); font-size: 11px; flex-shrink: 0; }
.hist-kind { font-weight: 600; color: var(--c-ink); }
/* Which agent took the call, once a workspace holds several. Deliberately the
 * same quiet treatment as .hist-when: the kind of call is what you scan a
 * history for and the agent is what you check afterwards, so it sits beside the
 * heading without competing with it. The separator is a text node written in
 * views.js rather than a ::before — a generated dot is not selectable and does
 * not survive a copy of the row. Rows recorded before agents were named carry
 * no name at all, and no placeholder either.
 *
 * margin-right: auto is what keeps it beside the kind rather than adrift: this
 * is a third item in a `space-between` row, and without the auto margin the
 * free space would be split around it and park the agent's name in the middle
 * of the row, between two things it has nothing to do with. */
.hist-agent { color: var(--c-ink-3); font-size: 11px; font-weight: 400;
              min-width: 0; overflow-wrap: anywhere; margin-right: auto; }
.hist-bot { display: flex; justify-content: space-between; align-items: baseline;
            gap: 8px; flex-wrap: wrap; }
.hist-to { color: var(--c-ink-2); }
.hist-duration { color: var(--c-ink-3); }
/* A dash means "no value", never a zero. MAGI's Nil. */
.hist-empty { color: var(--c-ink-3); font-size: 12.5px; padding: 18px 4px; }

/* ── admin view ───────────────────────────────────────── */
.admin-grid { display: grid; grid-template-columns: 300px 1fr; gap: 12px;
              align-items: start; }
.code-out { font-family: var(--mono); font-size: 12px; color: var(--c-ink);
            user-select: all; min-height: 0; word-break: break-all;
            padding-top: 6px; }
/* The minted code is shown exactly once, so it gets the weight of a thing you
   must act on now rather than a line of status text you skim past. */
.mint-label { font-family: var(--font); font-size: 11px; color: var(--c-ink-3);
              text-transform: uppercase; letter-spacing: .04em; }
.mint-code { font-family: var(--mono); font-size: 16px; font-weight: 600;
             color: var(--c-ink); user-select: all; word-break: break-all;
             margin: 4px 0 6px; }
.mint-warn { font-family: var(--font); font-size: 11px; color: var(--c-alarm-ink);
             line-height: 1.4; margin-bottom: 8px; }
.mint-copy { font-size: 11px; padding: 4px 10px; }
/* Sending the code on is the step that follows copying it, so it sits below
   the Copy button behind the same hairline the rest of this card uses — one
   more action on the card, not a second card. */
/* .code-out sets `user-select: all` so the code can be grabbed in one click.
   Inherited into a text field that means clicking to place a cursor selects
   the whole line instead, so it is undone here. */
.mint-mail { margin-top: 12px; padding-top: 10px;
             border-top: 1px solid var(--c-line);
             user-select: text; word-break: normal; }
/* Which of the three notes goes out. It sits ABOVE the address field because
   it is the decision you make first — picking it after typing the addresses
   means re-reading the row you already filled in to check what you are about
   to send them. */
.mint-mail-focus { display: flex; gap: 6px; align-items: center;
                   margin-bottom: 6px; }
.mint-mail-focuslab { font-family: var(--font); font-size: 11px;
                      color: var(--c-ink-3); text-transform: uppercase;
                      letter-spacing: .04em; flex: 0 0 auto; }
.mint-mail-sel { flex: 1 1 auto; min-width: 0; font-family: var(--font);
                 font-size: 11.5px; padding: 4px 8px; }
.mint-mail-row { display: flex; gap: 6px; align-items: center;
                 flex-wrap: wrap; }
.mint-mail-input { flex: 1 1 150px; min-width: 0; font-family: var(--font);
                   font-size: 11.5px; padding: 4px 8px; }
.mint-mail-btn { font-size: 11px; padding: 4px 10px; white-space: nowrap; }
.mint-mail-note { font-family: var(--font); font-size: 11px; line-height: 1.4;
                  color: var(--c-ink-3); margin-top: 6px; }
.mint-mail-note:empty { display: none; }
.mint-mail-note.good { color: var(--c-ink-2); }
.mint-mail-note.bad { color: var(--c-alarm-ink); }
.brand-row { display: flex; flex-direction: column; gap: 2px; padding: 9px 0;
             border-top: 1px solid var(--c-line); font-size: 12px; }
.brand-row:first-child { border-top: none; }
.brand-row .b-top { display: flex; justify-content: space-between;
                    align-items: center; gap: 8px; }
.brand-row .b-name { font-weight: 600; color: var(--c-ink); }
.brand-row .b-side { display: flex; align-items: center; gap: 8px; }
.brand-row .b-exp { color: var(--c-ink-3); font-size: 11px; white-space: nowrap; }
/* Expiring soon is the same "unresolved" fact the latency table calls `warn`,
 * so it gets the same amber hatch mark rather than a lone amber word. */
.brand-row .b-exp.soon { color: var(--c-open-ink); font-weight: 600; }
.brand-row .b-exp.soon::before {
  content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 1px;
  margin-right: 5px; border: 1px solid var(--c-open-line);
  background-color: var(--c-open-bg);
  background-image: repeating-linear-gradient(45deg,
    var(--c-open-hatch) 0 3px, transparent 3px 7px);
}
.brand-row .b-code { font-family: var(--mono); color: var(--c-ink-2);
                     font-size: 11.5px; user-select: all; word-break: break-all; }
.b-del { background: none; border: none; color: var(--c-ink-3); cursor: pointer;
         padding: 0 3px; font-size: 12px; line-height: 1; }
.b-del:hover { color: var(--c-alarm-ink); background: none; transform: none; }
.b-imp, .b-rot { background: none; border: 1px solid var(--c-line-strong);
         color: var(--c-ink-2); cursor: pointer; border-radius: 3px;
         padding: 2px 7px; font-size: 11px; line-height: 1.4;
         white-space: nowrap; }
.b-imp:hover { color: var(--accent); border-color: var(--accent-line);
               background: var(--accent-bg); transform: none; }
/* Amber, not green: rotating invalidates a code somebody is holding. */
.b-rot:hover { color: var(--c-open-ink); border-color: var(--c-open-line);
               background: var(--c-open-bg); transform: none; }

/* ── impersonation bar ────────────────────────────────────────────────────
 * Amber, because in this palette amber is "unresolved" and a session that is
 * an admin wearing a customer's workspace is the definition of unresolved. It
 * carries the ring as well as the hue (rule 2): meaning is never in colour
 * alone, and this is the one state on the page that must not be missed. */
.impbar { position: sticky; top: 0; z-index: 60;
          display: flex; align-items: center; gap: 9px;
          padding: 7px clamp(12px, 2vw, 24px);
          background: var(--c-open-bg);
          border-bottom: 1px solid var(--c-open-line);
          color: var(--c-open-ink); font-size: 12px; }
.impbar .imp-dot { width: 8px; height: 8px; border-radius: 50%;
                   border: 2px solid var(--c-open); flex: none; }
.impbar .imp-text { min-width: 0; }
.impbar b { font-weight: 600; }
.impbar button { margin-left: auto; flex: none;
                 background: var(--c-surface); color: var(--c-open-ink);
                 border: 1px solid var(--c-open-line); border-radius: 3px;
                 padding: 3px 11px; font-size: 11.5px; cursor: pointer; }
.impbar button:hover { background: var(--c-open-bg); transform: none; }
.impbar button:disabled { opacity: .55; cursor: default; }

/* ── a campaign that is somebody else's wave ──────────────────────────────
 * Demoted, never hidden: it ran, it has its own dial log, and a customer
 * looking for "Servall Recovery" has to find it. What it does not have is a
 * report of its own — its numbers are in the primary's union — so the row is
 * quieter by a notch and says whose wave it is. One notch, not a grey-out:
 * a row nobody can read is a row somebody will ask us about. */
.cmp-row.cmp-wave .cmp-c-title { font-weight: 500; color: var(--c-ink-2); }
.cmp-row.cmp-wave .cmp-c-who { font-style: italic; }
.cmp-wavenote { margin: -2px 0 10px; }

/* ── the campaign report, framed by the console ───────────────────────────
 * The deck is a whole document — its own stylesheet, its own scroll-snap, its
 * own keyboard — so it is served into an <iframe> rather than merged into this
 * page, and everything here is the CHROME around it. That chrome is pure
 * console: the same hairline, the same 10px uppercase label, the same link
 * weight for a back control that the detail pane already uses. Nothing in this
 * block styles the report itself, and nothing in the report reaches out here.
 *
 * A fixed height rather than a growing one, because the deck inside pages by
 * viewport: a frame that is as tall as its content would leave the reader
 * scrolling this page and the report at the same time. */
#campreport { display: flex; flex-direction: column; min-height: 480px;
              height: calc(100dvh - 32px); }
.rpt-bar { display: flex; align-items: center; gap: 12px;
           padding: 0 0 10px; margin-bottom: 10px;
           border-bottom: 1px solid var(--c-line); }
.rpt-bar .linkbtn { padding-left: 0; flex: none; }
.rpt-title { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.rpt-title b { font-size: 13px; font-weight: 600; letter-spacing: -0.01em;
               color: var(--c-ink); white-space: nowrap; overflow: hidden;
               text-overflow: ellipsis; display: block; }
.rpt-title span { font-size: 10px; letter-spacing: 0.08em;
                  text-transform: uppercase; font-weight: 600;
                  color: var(--c-ink-3); line-height: 1; }
/* The spreadsheet, on the page people read the report on. `margin-left: auto`
 * moves to the FIRST of the trailing controls, so the group stays right-aligned
 * as one block rather than each control finding its own edge. */
.rpt-csv { margin-left: auto; }
.rpt-bar button.ghost { flex: none; }
.rpt-full { flex: none; font-size: 11.5px; font-weight: 500;
            color: var(--c-ink-3); text-decoration: none; white-space: nowrap; }
.rpt-full:hover { color: var(--c-ink); }
/* No shadow and no inner padding: the deck draws its own sheets, and a frame
 * with a border AND a page with a border is two boxes for one thing. */
#cdrptframe { flex: 1 1 auto; display: block; width: 100%; min-height: 0;
              border: 1px solid var(--c-line); border-radius: var(--r-md);
              background: var(--c-paper); }

/* ── developers view ──────────────────────────────────────────────────────
 * Four cards in a two-column grid: keys and workspace on top, the two things
 * you copy underneath at full width, because a wrapped shell command is a
 * shell command someone pastes wrong.
 *
 * The secret reveal is the only place in this build that draws attention to
 * itself, and it earns it: the key is on screen once and never again. It uses
 * `open` amber with the hatch — unresolved, "you still have to do something
 * with this" — never alarm red, which belongs to a thing that broke. */
/* Two sub-tabs — APIs, SDK — flipped client-side by dev.js. There were three;
 * TTS is a view of its own now, because it is a product with a price rather
 * than a section of the page about credentials. Built from .howrow/.howtab,
 * which the comment above them calls a generic switch and which the tier strip
 * already borrows: one hairline box split into equal parts, the selected part
 * carrying weight AND ink AND the paper. Nothing new is invented here.
 *
 * They shipped as a .chip strip and it was wrong: three small pills floating
 * above a grid of cards read as filters ON those cards rather than as the
 * choice of which page you are looking at. Edge to edge, they cannot.
 *
 * `.howrow.devrow`, not `.devrow` — the base .howrow is declared later in this
 * file, so at equal specificity it would win and this rule's margin would be
 * lost. Same trap .tierrow documents; do not simplify it, and do not delete
 * the column count just because it happens to match the base again today.
 *
 * Hidden panels use [hidden], but .dev-grid sets `display: grid`, which beats
 * the UA's `display: none` on [hidden] — so it has to be said again here. */
.howrow.devrow { grid-template-columns: repeat(2, 1fr); margin: 0 0 12px; }
.devrow .howtab { min-height: 42px; padding: 8px 4px; line-height: 1.2;
                  white-space: nowrap; }
.devrow .howtab b { font-size: 13px; font-weight: 600; letter-spacing: 0.01em; }
.dev-panel[hidden] { display: none; }

.dev-grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px;
            gap: 12px; align-items: start; }
.dev-wide { grid-column: 1 / -1; }
.dev-cardhead { display: flex; align-items: baseline; justify-content: space-between;
                gap: 10px; }
.dev-ws { font-family: var(--mono); font-size: 11px; color: var(--c-ink-3);
          user-select: all; }
.dev-help { color: var(--c-ink-3); font-size: 12px; margin: 6px 0 0; }
.dev-help code { font-family: var(--mono); font-size: 11.5px;
                 color: var(--c-ink-2); background: var(--c-sunk);
                 padding: 1px 4px; border-radius: var(--r-xs); }
.dev-empty { color: var(--c-ink-3); font-size: 12.5px; padding: 14px 2px; }

.dev-mintrow { display: flex; gap: 8px; margin-top: 12px; }
.dev-mintrow input { flex: 1; min-width: 0; }
.dev-mintrow button { white-space: nowrap; }

.dev-reveal { margin-top: 12px; padding: 11px 12px; border-radius: var(--r-sm);
              background: var(--c-open-bg); border: 1px solid var(--c-open-line); }
.dev-revealhead { font-size: 12px; color: var(--c-open-ink); }
.dev-revealhead::before {
  content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 1px;
  margin-right: 6px; border: 1px solid var(--c-open-line);
  background-color: var(--c-surface);
  background-image: repeating-linear-gradient(45deg,
    var(--c-open-hatch) 0 3px, transparent 3px 7px);
}
.dev-secret { display: flex; align-items: center; gap: 8px; margin-top: 9px; }
.dev-seclabel { font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase;
                color: var(--c-ink-3); flex: 0 0 96px; }
.dev-secret code { flex: 1; min-width: 0; font-family: var(--mono);
                   font-size: 12px; color: var(--c-ink); user-select: all;
                   word-break: break-all; }

.dev-fact { display: flex; justify-content: space-between; align-items: baseline;
            gap: 10px; padding: 7px 0; border-top: 1px solid var(--c-line);
            font-size: 12px; }
.dev-fact:first-of-type { border-top: none; }
.dev-fact span { color: var(--c-ink-3); }
.dev-fact b { color: var(--c-ink); font-weight: 600; }
.dev-fact b.dev-mono { font-family: var(--mono); font-size: 11px; font-weight: 500;
                       word-break: break-all; text-align: right; }
.dev-docs { display: inline-flex; align-items: center; gap: 7px; margin-top: 12px;
            font-size: 12px; font-weight: 600; color: var(--c-ink);
            text-decoration: none; }
.dev-docs svg { width: 15px; height: 15px; color: var(--c-ink-3); }
.dev-docs:hover { color: var(--accent); }
.dev-docs:hover svg { color: var(--accent); }

/* The two Postman files. A download is a file, so the row is shaped like one:
 * the filename in mono at the top and the sentence that says what it is
 * underneath — the same statement/description stacking .dev-keyrow uses, so a
 * key and a file read as the same kind of listed thing. */
.dev-dl { display: flex; flex-direction: column; gap: 2px; margin-top: 10px;
          padding: 10px 12px; border: 1px solid var(--c-line);
          border-radius: var(--r-sm); background: var(--c-sunk);
          text-decoration: none; }
.dev-dlname { font-family: var(--mono); font-size: 12px; color: var(--c-ink);
              font-weight: 500; word-break: break-all; }
.dev-dlname::after { content: " ↓"; color: var(--c-ink-3); }
.dev-dlnote { font-size: 11.5px; line-height: 1.45; color: var(--c-ink-3); }
@media (hover: hover) and (pointer: fine) {
  .dev-dl:hover { border-color: var(--c-ink-4); background: var(--c-surface); }
  .dev-dl:hover .dev-dlname::after { color: var(--accent); }
}

/* The copy blocks. The card does not scroll — the code inside it does. Put the
 * overflow on the wrapper instead and the Copy button, being positioned
 * against it, slides out of view the moment a wide line is scrolled. Content
 * never wraps: a soft-wrapped snippet reads as broken code, and this one is
 * meant to run exactly as pasted. */
.dev-cmd { position: relative; margin-top: 10px; padding: 10px 12px;
           background: var(--c-dark); border-radius: var(--r-sm);
           font-family: var(--mono); font-size: 12px; color: var(--c-on-dark);
           overflow: hidden; }
.dev-cmd code, .dev-cmd pre {
  display: block; margin: 0; padding-right: 62px; overflow-x: auto;
  white-space: pre; line-height: 1.55; color: var(--c-on-dark);
  user-select: all;
  scrollbar-width: thin; scrollbar-color: var(--c-dark-line) transparent; }
.dev-copy { position: absolute; top: 8px; right: 8px; z-index: 1;
            background: var(--c-dark-sunk); color: var(--c-on-dark-2);
            border: 1px solid var(--c-dark-line); border-radius: var(--r-xs);
            font-size: 11px; padding: 4px 9px; cursor: pointer; }
.dev-copy:hover { background: var(--c-dark-line); color: var(--c-on-dark);
                  transform: none; }
.dev-copy.done { color: var(--accent-on-dark); }

.dev-keyrow { display: flex; flex-direction: column; gap: 3px; padding: 10px 0;
              border-top: 1px solid var(--c-line); font-size: 12px; }
.dev-keyrow .k-top { display: flex; justify-content: space-between;
                     align-items: center; gap: 8px; }
.dev-keyrow .k-label { font-weight: 600; color: var(--c-ink); }
.dev-keyrow .k-side { display: flex; align-items: center; gap: 4px; }
.dev-keyrow .k-bot { display: flex; justify-content: space-between;
                     align-items: baseline; gap: 8px; }
.dev-keyrow .k-mask { font-family: var(--mono); font-size: 11.5px;
                      color: var(--c-ink-2); }
.dev-keyrow .k-when { color: var(--c-ink-3); font-size: 11px; white-space: nowrap; }
/* A revoked key is kept on screen — it is the record of a rotation — but it is
 * struck through and dimmed so it can never be mistaken for a live one. */
.dev-keyrow.gone .k-label, .dev-keyrow.gone .k-mask {
  color: var(--c-ink-3); text-decoration: line-through; }
.k-act { background: none; border: 1px solid var(--c-line-strong);
         border-radius: var(--r-xs); color: var(--c-ink-2);
         font-size: 11px; padding: 3px 8px; cursor: pointer; }
.k-act:hover { background: var(--c-sunk); color: var(--c-ink); transform: none; }
.k-act.danger:hover { color: var(--c-alarm-ink); background: var(--c-alarm-bg);
                      border-color: var(--c-alarm-line); }

/* The card that says the synthesiser moved out. It is a ROW, not another
 * stacked card: it is a signpost, and a signpost that looks like the cards
 * around it gets read as more of the same content. The button is a real
 * button because core.js routes [data-view] through navTo() — an <a href> here
 * would reload the whole console to reach a view that is one swap away. */
.dev-pointer { display: flex; align-items: center; justify-content: space-between;
               gap: 14px; flex-wrap: wrap; }
.dev-pointerbody { flex: 1 1 260px; min-width: 0; }
.dev-pointergo { display: inline-flex; align-items: center; gap: 7px;
                 flex: 0 0 auto; white-space: nowrap; }
.dev-pointergo svg { width: 15px; height: 15px; }

/* ── text to speech view ──────────────────────────────────────────────────
 * The synthesiser sold as itself. Everything in here was either moved out of
 * the developers section above — the playground, renamed out of the `dev`
 * namespace on the way — or is new furniture for the four sections that were
 * built around it: the facts strip, the quickstart switch and the languages
 * promise. The catalogue borrows .sc-item from the gate wholesale and adds
 * nothing, which is the point of having a row pattern.
 *
 * One column, deliberately. .dev-grid's 300px sidebar exists because the
 * Developers page has a genuine aside (the wallet); this page is an argument
 * read top to bottom and a second column would let the eye skip the order it
 * is being made in. */
.tts-grid { display: flex; flex-direction: column; gap: 12px; }

/* The three things you need before you can send a request. Same number-over-
 * label shape as the measurements below, and that repetition is intentional:
 * they are the same kind of statement, and one of them is measured live a card
 * later.
 *
 * Nothing in here is tinted. The price used to lead this strip in accent ink,
 * and accent ink on the one placeholder figure among three settled facts read
 * as the commitment on the page. The price is gone; the emphasis went with
 * it, rather than being handed to whichever fact happened to be first. */
.tts-facts { display: flex; flex-wrap: wrap; gap: 10px 26px; margin: -8px 0 16px;
             padding: 11px 14px; border: 1px solid var(--c-line);
             border-radius: var(--r-md); background: var(--c-surface); }
.tts-facts span { display: flex; flex-direction: column; gap: 2px; }
.tts-facts b { font-family: var(--mono); font-size: 14px; font-weight: 600;
               color: var(--c-ink); font-variant-numeric: tabular-nums; }
.tts-facts i { font-style: normal; font-size: 11px; color: var(--c-ink-3); }

/* The playground. One row under the textarea: which voice says it, the button
 * that says it, then what you can do with what it said — left to right, in the
 * order the thing actually happens.
 *
 * The textarea is the point of the card and is sized like it: a paragraph of
 * Devanagari fits without scrolling, because a three-row box invites a
 * three-word demo and three words is exactly the input that makes our own
 * synthesiser sound bad. `resize: vertical` only — a horizontally resizable
 * box inside a grid column can push the card past its own gutter. */
.tts-text { width: 100%; margin-top: 12px; resize: vertical;
            min-height: 140px; font-family: inherit; line-height: 1.5; }
.tts-bar { display: flex; align-items: center; gap: 8px; margin-top: 10px;
           flex-wrap: wrap; }
.tts-bar select { flex: 0 0 auto; }
.tts-bar button { white-space: nowrap; }
/* The word-count nudge, in the row rather than under it: it is the reason the
 * button beside it is disabled, so it has to sit where the eye already is. */
.tts-hint { flex: 1 1 160px; min-width: 0; font-size: 11.5px;
            color: var(--c-ink-3); }

/* Starter lines, ABOVE the box rather than below it: they are what you press
 * instead of typing, so they have to be seen before the empty textarea has
 * been stared at, not offered as a consolation afterwards. Wraps rather than
 * scrolls — a horizontal scroller hides the last option, and the last option
 * here is the long sentence that shows prosody. */
.tts-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.tts-pills .chip { cursor: pointer; }

/* The measurements. A row of number-over-label pairs, mono for the figures so
 * a run that is 40 ms slower does not also re-flow the row and look like a
 * different layout. Wraps to two lines on a phone; nothing is truncated,
 * because a half-shown latency figure is worse than a wrapped one. */
.tts-stats { display: flex; flex-wrap: wrap; gap: 8px 22px; margin-top: 12px;
             padding: 10px 12px; border: 1px solid var(--c-line);
             border-radius: var(--r-sm); background: var(--c-surface); }
.tts-stats span { display: flex; flex-direction: column; gap: 1px; }
.tts-stats b { font-family: var(--mono); font-size: 13px; font-weight: 600;
               color: var(--c-ink); font-variant-numeric: tabular-nums; }
.tts-stats i { font-style: normal; font-size: 11px; color: var(--c-ink-3); }
/* The headline of the five. Everything else is context for this one. */
.tts-stats span:first-child b { color: var(--accent); }

/* The quickstart switch. Three columns, and `.howrow.qsrow` rather than
 * `.qsrow` for exactly the reason .devrow and .tierrow both document: the base
 * .howrow is declared later in this file and would win at equal specificity,
 * wrapping the third tab onto its own row. The labels are code (`curl`,
 * `POST /v2/tts`), so they are allowed to be mono and they are not allowed to
 * wrap — a route name broken across two lines stops being a route name. */
.howrow.qsrow { grid-template-columns: repeat(3, 1fr); margin: 12px 0; }
.qsrow .howtab { min-height: 42px; padding: 8px 4px; line-height: 1.2;
                 white-space: nowrap; }
.qsrow .howtab b { font-family: var(--mono); font-size: 12px; font-weight: 600;
                   letter-spacing: 0; }

/* What is live and what is coming, as two rows of different weight rather than
 * two sections. The live ones are cards: the solid mark AND the word "live"
 * AND the ink, never colour alone (rule 2), so the state survives greyscale
 * and a half-loaded stylesheet. `flex: 1 1 180px` puts the three of them on
 * one line on a desktop and stacks them cleanly when there is not room.
 *
 * The six in evaluation are NAMES ONLY, at label size, on one line. They are
 * announced — the docs page lists them — so printing them is honest; giving
 * them the same card as a language you can call today would not be. No dates,
 * on purpose and as policy: each is announced when it clears evaluation. */
.tts-langs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.tts-lang { display: flex; flex-direction: column; gap: 3px; flex: 1 1 180px;
            padding: 10px 12px; border: 1px solid var(--c-line);
            border-radius: var(--r-sm); background: var(--c-sunk); }
.tts-lang b { font-size: 12.5px; font-weight: 600; color: var(--c-ink-3); }
.tts-lang i { font-style: normal; font-size: 11.5px; line-height: 1.45;
              color: var(--c-ink-3); }
.tts-lang.on { background: var(--c-surface); border-color: var(--c-line-strong); }
.tts-lang.on b { color: var(--c-ink); }
.tts-lang.on b::before {
  content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 1px;
  margin-right: 6px; background: var(--c-ink);
}

.tts-soon { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 8px;
            margin-top: 10px; padding-top: 10px;
            border-top: 1px solid var(--c-line); }
/* The label is the same eyebrow every other section header in the build uses,
 * so the row reads as a list with a heading rather than as seven equal chips
 * one of which happens to be English. */
.tts-soon b { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
              font-weight: 600; line-height: 1; color: var(--c-ink-3);
              margin-right: 2px; }
/* Hollow, not filled: the live cards above carry a solid mark, and these carry
 * the absence of one. Same distinction, one glance apart. */
.tts-soon span { font-size: 11.5px; line-height: 1; color: var(--c-ink-2);
                 padding: 5px 8px; border-radius: var(--r-xs);
                 border: 1px dashed var(--c-line-strong); }

/* ── language model view ──────────────────────────────────────────────────
 * THE CHAT IS THE PAGE. Everything else on this view is arranged around that
 * one decision, and the decision is not decoration: a synthesiser proves
 * itself in two seconds and a language model does not, so the control that
 * does the proving has to be big enough to keep somebody in it.
 *
 * Two columns. Not .dev-grid — that is a grid of cards with a 300px aside,
 * and this is a FIXED-HEIGHT chat beside a scrolling rail, which behaves
 * differently the moment the window changes size. `align-items: start` so the
 * rail hugs its content instead of stretching to the chat's height and
 * leaving a tall empty card. */
/* ONE COLUMN IS THE DEFAULT, and that is the safe direction: the chat gets the
 * full width and the settings stack under it. The second column is an
 * enhancement that only appears when it has been measured to fit, so a browser
 * that does not do container queries lands on the layout that keeps the
 * conversation biggest rather than on a 300px chat.
 *
 * 820px because that is where the chat still gets ~540px after the rail and
 * the gutter — below it the two-column version is worse than no rail at all. */
.llm-shell { container-type: inline-size; }
.llm-work { display: grid; grid-template-columns: 1fr; gap: 12px; }
@container (min-width: 820px) {
  .llm-work { grid-template-columns: minmax(0, 1fr) 264px; align-items: start; }
}
/* Stacked but wide: the two settings cards sit side by side rather than
 * becoming a tall column the chat has to be scrolled past. */
@container (max-width: 819px) {
  .llm-side { flex-direction: row; align-items: flex-start; }
  .llm-side > * { flex: 1 1 260px; }
}
@container (max-width: 560px) {
  .llm-side { flex-direction: column; }
}
.llm-below { margin-top: 12px; }

/* The conversation column. Height from the VIEWPORT, not from the content —
 * that is what stops the composer wandering down the page as the transcript
 * grows, which is the single thing that makes a chat feel like a chat rather
 * than a form with a log under it. clamp() so it cannot collapse on a laptop
 * in a short window or run away on a 4K display; the same shape #campreport
 * uses, and the same --vvh arithmetic on a phone further down.
 *
 * `padding: 0` and the parts inset themselves: the transcript needs to scroll
 * edge to edge under the footer, and a card's own padding would leave a strip
 * of paper above the scrollbar that reads as a rendering bug. */
.llm-chatcol { display: flex; flex-direction: column; padding: 0;
               height: clamp(460px, calc(100dvh - 232px), 780px);
               overflow: hidden; }

/* The transcript. The ONLY thing that grows; min-height: 0 because a flex
 * child will not shrink below its content without it and the scrollbar would
 * end up on the page instead of in here. */
.llm-chat { flex: 1 1 auto; min-height: 0; overflow-y: auto;
            display: flex; flex-direction: column; gap: 12px;
            padding: 14px 16px; scrollbar-width: thin; }

/* The empty state. One line of instruction and the page's own glyph, greyed —
 * it is a prompt, not a placeholder graphic, and it says the one thing a
 * visitor needs to know before typing (that the tools are armed). */
.llm-empty { margin: auto; display: flex; flex-direction: column;
             align-items: center; text-align: center; gap: 7px;
             max-width: 44ch; padding: 12px 0; color: var(--c-ink-3); }
.llm-empty svg { width: 26px; height: 26px; color: var(--c-line-strong); }
.llm-empty b { font-size: 13px; font-weight: 600; color: var(--c-ink-2); }
.llm-empty span { font-size: 11.5px; line-height: 1.55; }

/* Speaker over line, not bubbles on two sides. A two-sided bubble layout is a
 * messaging-app idiom; this is a transcript being read and compared, the same
 * shape the call transcript already uses. The user's turn is inset and sits on
 * the sunk paper so the two sides are still distinguishable at a glance
 * WITHOUT relying on which edge they are near — greyscale, and rule 2. */
.llm-msg { display: flex; flex-direction: column; gap: 3px; }
.llm-msg.user { align-self: flex-end; align-items: flex-end; max-width: 82%;
                padding: 8px 11px; border-radius: var(--r-sm);
                background: var(--c-sunk); border: 1px solid var(--c-line); }
.llm-who { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
           font-weight: 600; line-height: 1; color: var(--c-ink-3); }
.llm-msg.bot .llm-who { color: var(--accent); }
.llm-body { margin: 0; font-size: 13px; line-height: 1.6; color: var(--c-ink);
            white-space: pre-wrap; overflow-wrap: anywhere; }
.llm-msg.user .llm-body { color: var(--c-ink-2); text-align: left; }

/* The footer: the flex item that does NOT grow. Its top hairline is the only
 * thing separating it from the transcript, so a message scrolled to the bottom
 * meets an edge rather than dissolving into the composer. */
.llm-foot { flex: 0 0 auto; padding: 10px 16px 14px;
            border-top: 1px solid var(--c-line); background: var(--c-surface); }
.llm-statrow { margin: 0 0 8px; }
/* The explanation, as a disclosure. Four lines of standing prose inside a chat
 * window is four lines the conversation does not get. */
.llm-note { margin: 0 0 8px; }
.llm-note summary { font-size: 11px; color: var(--c-ink-3); cursor: pointer;
                    user-select: none; }
.llm-note summary:hover { color: var(--accent); }
.llm-note .dev-help { margin-top: 6px; }
.llm-starters { margin: 0 0 9px; }
.llm-input { width: 100%; resize: none; min-height: 58px; max-height: 160px;
             font-family: inherit; font-size: 13px; line-height: 1.5; }
/* Send on the right, where the eye ends: the hint takes the slack so the two
 * buttons stay put as it appears and disappears. */
.llm-send { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.llm-send .tts-hint { flex: 1 1 auto; }
.llm-send button { white-space: nowrap; }

/* ── the settings rail ─────────────────────────────────────────────────────
 * Beside the chat and never in front of it. Both cards are disclosures for the
 * same reason: these are the second thing anybody touches, and a system prompt
 * textarea standing open beside an empty conversation is a form asking to be
 * filled in before the interesting part. */
.llm-side { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.llm-sidecard { padding: 12px 14px; }
.llm-det summary { display: flex; align-items: baseline; gap: 8px;
                   cursor: pointer; user-select: none; }
.llm-det summary b { font-size: 13px; font-weight: 600; color: var(--c-ink);
                     letter-spacing: -0.01em; }
.llm-det summary i { font-style: normal; font-size: 11px; color: var(--c-ink-3); }
.llm-det summary:hover b { color: var(--accent); }
.llm-sysbox { width: 100%; margin-top: 10px; resize: vertical; min-height: 96px;
              font-family: inherit; font-size: 12.5px; line-height: 1.5; }

/* The tool toggles. Rows rather than chips: each carries a function name AND a
 * sentence about what it demonstrates, and a chip cannot hold the sentence. An
 * armed one gains the paper, the border and the ink — three axes, so "on"
 * survives greyscale (rule 2) instead of being a tint on a checkbox. */
.llm-tools { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
.llm-tool { border: 1px solid var(--c-line); border-radius: var(--r-sm);
            background: var(--c-sunk); padding: 8px 10px; }
.llm-tool.on { background: var(--c-surface); border-color: var(--c-line-strong); }
.llm-toolhead { display: flex; align-items: flex-start; gap: 8px;
                cursor: pointer; }
.llm-tool input { margin: 2px 0 0; flex: none; accent-color: var(--c-ink); }
.llm-tool-txt { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.llm-tool-txt code { font-family: var(--mono); font-size: 11.5px;
                     color: var(--c-ink-3); }
.llm-tool.on .llm-tool-txt code { color: var(--c-ink); font-weight: 600; }
.llm-tool-txt i { font-style: normal; font-size: 11px; line-height: 1.45;
                  color: var(--c-ink-3); }
/* The schema, one click down. It is the thing a developer checks second — the
 * arguments they will be parsing — and the thing nobody needs while reading
 * the sentence above it. */
.llm-schema { margin-top: 7px; }
.llm-schema summary { font-size: 10.5px; color: var(--c-ink-3);
                      cursor: pointer; user-select: none; }
.llm-schema summary:hover { color: var(--accent); }

/* ── a tool call, as it happens ────────────────────────────────────────────
 * The centrepiece of the transcript, so it is the one thing in it that is
 * drawn rather than typeset: a bordered block with the function, the arguments
 * the model extracted, and what went back. The left edge is a 2px rule in ink
 * — the same "this is a step, not a message" signal the campaign report uses.
 *
 * "simulated" is a WORD on the chip rather than a footnote under the card,
 * because this is the exact spot where somebody screenshots the page. */
.llm-tool-call { border: 1px solid var(--c-line-strong);
                 border-left: 2px solid var(--c-ink);
                 border-radius: var(--r-sm); background: var(--c-surface);
                 padding: 10px 12px; }
.llm-tc-head { display: flex; align-items: baseline; justify-content: space-between;
               gap: 10px; }
.llm-tc-fn { font-family: var(--mono); font-size: 12.5px; font-weight: 600;
             color: var(--c-ink); }
.llm-tc-tag { font-size: 9.5px; letter-spacing: 0.06em; text-transform: uppercase;
              font-weight: 600; line-height: 1; padding: 3px 5px;
              border-radius: var(--r-xs); color: var(--c-ink-3);
              border: 1px dashed var(--c-line-strong); white-space: nowrap; }
.llm-tc-args { margin: 7px 0 0; font-family: var(--mono); font-size: 11.5px;
               line-height: 1.5; color: var(--c-ink-2); white-space: pre-wrap;
               overflow-wrap: anywhere; }
/* Arguments that would not parse. A real failure mode, shown rather than
 * hidden — and marked on two axes, since colour alone is not a state. */
.llm-tc-args.bad { color: var(--c-alarm-ink); text-decoration: underline wavy; }
.llm-tc-result { margin-top: 9px; padding-top: 8px;
                 border-top: 1px solid var(--c-line); }
.llm-tc-rlabel { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
                 font-weight: 600; line-height: 1; color: var(--c-ink-3); }

/* The disclosure. For this audience the wire IS the product, so the raw JSON
 * is one click from every call — closed by default because it is the second
 * question, never the first. Dark, like every other code block in the build. */
.llm-wire { margin-top: 9px; }
.llm-wire summary { font-size: 11px; color: var(--c-ink-3); cursor: pointer;
                    user-select: none; }
.llm-wire summary:hover { color: var(--accent); }
.llm-wirelab { margin-top: 8px; font-size: 10px; letter-spacing: 0.08em;
               text-transform: uppercase; font-weight: 600; line-height: 1;
               color: var(--c-ink-3); }
.llm-wirepre { margin: 5px 0 0; padding: 9px 11px; border-radius: var(--r-sm);
               background: var(--c-dark); color: var(--c-on-dark);
               font-family: var(--mono); font-size: 11px; line-height: 1.5;
               overflow-x: auto; white-space: pre;
               scrollbar-width: thin;
               scrollbar-color: var(--c-dark-line) transparent; }

/* ── GPU banner ───────────────────────────────────────────────────────────
 * The fleet being off is UNRESOLVED, not broken — it is a node to bring back,
 * not a call that failed — so it is `open` amber with the hatch, never `alarm`
 * red. Red belongs to a call that failed.
 *
 * The amber used to be justified by there still being a vendor-voiced tier the
 * sandbox could serve without a GPU. That tier is gone (owner, 2026-08-20) and
 * a dark fleet now means no tier can speak, which is a stronger statement than
 * this banner makes. Left amber deliberately: the colour is a product call, and
 * the sentence the banner prints already says every tier is down.
 *
 * It sits first in the document and is sticky rather than fixed, so when it is
 * shown it takes its own row instead of covering the page heading.
 *
 * NO LAYOUT JUMP. It used to toggle `display`, which snapped the whole page
 * down 33px on a poll that runs every thirty seconds. It is now always in the
 * flow with a collapsed 0fr row, so showing it is one 220ms settle and hiding
 * it is the same in reverse. That transition fires on a state change only —
 * nothing here loops, per rule 3. The clip/in/row nesting is what makes the
 * amber ground and the bottom hairline collapse WITH the height instead of
 * leaving a 1px band across the top of an otherwise fine console.
 *
 * The copy is left-aligned to the app grid rather than centred: a centred
 * sentence spanning 1700px is a marketing banner, and this is an instrument
 * telling you one fact about your session. It carries the ▨ hatch mark — the
 * same one the response chart uses for "unresolved" — instead of the ⚠️ emoji
 * it used to open with. */
#gpu-status-banner {
  position: sticky; top: 0; z-index: 60;
  display: grid; grid-template-rows: 0fr;
  transition: grid-template-rows 220ms var(--ease-out);
}
#gpu-status-banner.on { grid-template-rows: 1fr; }
/* RULE 3, the arrival case. The fleet is probed after first paint, so without
 * this the banner SETTLES OPEN in front of someone who has just loaded the
 * page — the console performing its own state instead of showing it. `.instant`
 * is on the element in the markup and is dropped one frame after the first
 * probe lands, so the opening state is painted and every state change after it
 * is the 220ms settle. */
#gpu-status-banner.instant,
#gpu-status-banner.instant .gpu-banner-in { transition: none; }
.gpu-banner-clip { min-height: 0; overflow: hidden; }
/* A 0fr row is invisible but NOT gone: without this the mailto link inside it
 * stays in the tab order and in the accessibility tree, so the first Tab on a
 * healthy console lands on a link about a problem nobody can see. `visibility`
 * removes it from both. The delay holds it visible for the length of the
 * collapse so the text does not vanish before the row finishes closing —
 * a discrete step at the end of the settle, not a second animation. */
.gpu-banner-in { background: var(--c-open-bg);
                 border-bottom: 1px solid var(--c-open-line);
                 padding: calc(9px + env(safe-area-inset-top)) 0 9px;
                 visibility: hidden;
                 transition: visibility 0s linear 220ms; }
#gpu-status-banner.on .gpu-banner-in { visibility: visible;
                                       transition-delay: 0s; }
.gpu-banner-row { max-width: 1760px; margin: 0 auto;
                  padding: 0 clamp(12px, 2vw, 24px);
                  font-size: 12px; line-height: 1.6; color: var(--c-ink-2); }
.gpu-banner-row b { font-weight: 600; color: var(--c-open-ink); }
.gpu-banner-row a { color: var(--c-open-ink); font-weight: 500;
                    text-decoration: underline; text-underline-offset: 3px;
                    white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
  #gpu-status-banner { transition: none; }
  .gpu-banner-in { transition: none; }
}

/* ══ ACCESS GATE ═══════════════════════════════════════════════════════════
 * Two halves, one job each. LEFT is the dark editorial surface the founder's
 * note already uses on the Welcome view — same values, so the two screens read
 * as one product rather than as a landing page bolted onto a console. RIGHT is
 * paper, and it carries the two things a visitor can actually DO: type a code,
 * or play a call.
 *
 * The code field sits ABOVE the samples, not below them. Someone who arrived
 * holding a code has one job and it must not be under a scroll; someone who
 * arrived curious scrolls three inches and finds four production calls. Both
 * are served without either being made to read the other's screen.
 *
 * Rule 3 still holds — nothing here loops or performs on arrival. The card
 * settles once, and only because it is a modal-class surface (MAGI's dialog).
 */
#gate { position: fixed; inset: 0; background: var(--c-paper); z-index: 100;
        overflow-y: auto; overscroll-behavior: contain; }
#gate.hidden { display: none; }

.gate-grid { min-height: 100%; display: grid;
             grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
             align-items: stretch; }

/* ── left · the hero, then the argument ─────────────────────────────────────
 * .gate-left is the dark column on a desktop and `display: contents` on a
 * phone — see the note in index.html. The photograph lives on whichever of the
 * two is currently the box being drawn, which is why the background is
 * declared twice rather than once on a wrapper.
 *
 * THE SCRIM IS NOT DECORATION. The image is bright and busy across its lower
 * third (flowers, in full sun) and the copy sits over exactly that. Two layers
 * do the work: a wash that lifts the panel back toward --c-dark, and a
 * vertical gradient heaviest where the image is lightest. Measured over the
 * brightest pixel under the text, body copy holds ~9:1 — the same contrast it
 * has on the Welcome view, which is the point: this is the same surface with a
 * picture behind it, not a new one.
 *
 * background-color UNDER the image, so a slow or failed load is the dark panel
 * we already ship rather than white paper carrying white text. */
.gate-left { position: relative; isolation: isolate;
             background-color: var(--c-dark); color: var(--c-on-dark);
             background-image: url('/static/hero-login.jpg');
             background-size: cover; background-position: 50% 38%;
             background-repeat: no-repeat;
             border-right: 1px solid var(--c-dark-line);
             padding: clamp(28px, 4.4vw, 60px) clamp(24px, 4vw, 56px);
             display: flex; flex-direction: column; gap: 26px;
             justify-content: center; }
/* Light enough that the photograph is a photograph. The first version put a
 * 0.78-to-0.94 wash over the whole panel to make the founder's note legible,
 * and legible it was — over a picture you could no longer see, which is the
 * one thing a hero image has to do. Contrast is bought locally instead: this
 * gradient only anchors the top and the bottom edges, and the note carries its
 * own surface (below).
 *
 * ONE SCRIM PER BREAKPOINT, and that is why this selector is alone. The same
 * declaration exists again on .gate-hero::before inside the ≤940px block, and
 * the two must never be one shared rule: on a desktop .gate-hero is a CHILD of
 * .gate-left that `.gate-left > *` has already made positioned, so a shared
 * selector painted a second full gradient over the top of the column. The hero
 * came out a stop darker than the copy beneath it — same image, two washes.
 * Whichever element is the box carrying the photograph is the one that scrims
 * it: .gate-left here, .gate-hero there. */
.gate-left::before { content: ''; position: absolute;
  inset: 0; z-index: -1;
  background:
    linear-gradient(to bottom,
      rgba(21, 21, 19, 0.62) 0%,
      rgba(21, 21, 19, 0.28) 38%,
      rgba(21, 21, 19, 0.46) 66%,
      rgba(21, 21, 19, 0.90) 100%),
    linear-gradient(to right,
      rgba(21, 21, 19, 0.62) 0%,
      rgba(21, 21, 19, 0.26) 62%,
      rgba(21, 21, 19, 0.10) 100%); }
.gate-left > * { position: relative; }

.gate-hero { display: flex; flex-direction: column; gap: 22px; }
.gate-story { display: flex; flex-direction: column; gap: 26px; }

.gate-brand { display: flex; align-items: center; gap: 13px; }
/* Fixed HEIGHT, auto width. The mark is 174×112 and every place it appears is
 * height-constrained, so one rule keeps its proportions everywhere and no
 * layout has to know its aspect ratio. */
.gate-logo { height: 40px; width: auto; display: block; }
.gate-word { display: flex; flex-direction: column; gap: 2px; }
.gate-word b { font-size: 17px; font-weight: 600; letter-spacing: -0.02em;
               color: var(--c-on-dark); line-height: 1.1; }
.gate-word span { font-style: italic; font-size: 12px; color: var(--c-on-dark-3); }

.gate-lede { margin: 0; max-width: 30ch;
             font-size: clamp(19px, 2vw, 25px); line-height: 1.32;
             letter-spacing: -0.022em; font-weight: 500;
             color: var(--c-on-dark);
             /* the type sits on a photograph, so it carries its own separation
              * rather than relying on the scrim alone */
             text-shadow: 0 1px 14px rgba(21, 21, 19, 0.5); }
.gate-lede b { font-weight: 600; color: var(--accent-on-dark); }

/* The note sits on its own surface rather than directly on the photograph.
 * Three paragraphs of 12.5px body copy need a steady ground under every line,
 * and a photograph is by definition not steady — the alternative was darkening
 * the whole image until it stopped being one. Blur where the browser has it,
 * a flat translucent fill where it does not; both are legible, one is nicer. */
.gate-note { max-width: 52ch; padding: 20px 22px;
             border: 1px solid rgba(241, 239, 233, 0.14);
             border-radius: var(--r-md);
             background: rgba(21, 21, 19, 0.72); }
@supports (backdrop-filter: blur(1px)) {
  .gate-note { background: rgba(21, 21, 19, 0.44);
               backdrop-filter: blur(14px) saturate(120%);
               -webkit-backdrop-filter: blur(14px) saturate(120%); }
}
.gate-note .founder-kicker { color: var(--c-on-dark-3); margin-bottom: 13px; }
.gate-note p { margin: 0 0 11px; font-size: 12.5px; line-height: 1.72;
               color: var(--c-on-dark-2); }
.gate-note b { font-weight: 600; color: var(--c-on-dark); }
.gate-note .founder-sign { font-style: italic; font-size: 12.5px;
                           color: var(--c-on-dark-3); margin-top: 15px; }

/* Three facts, as a definition list, because that is what they are. The
 * figures sit on the ink ramp rather than in the accent: green means "mirai",
 * and three green numbers in a row would make it mean "good" instead. */
.gate-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
              gap: 16px; margin: 0; padding-top: 20px; max-width: 620px;
              border-top: 1px solid rgba(241, 239, 233, 0.20); }
.gate-facts dt, .gate-facts dd, .gate-brand, .gate-note .founder-kicker {
  text-shadow: 0 1px 10px rgba(21, 21, 19, 0.65); }
.gate-facts dt { font-size: 14px; font-weight: 600; letter-spacing: -0.015em;
                 color: var(--c-on-dark); line-height: 1.2; }
/* on-dark-2, not -3: these sit over the brightest part of the photograph (the
 * flowers, in full sun) and the quiet grey that reads fine on a flat dark panel
 * does not survive being laid over a sunlit image. */
.gate-facts dd { margin: 5px 0 0; font-size: 11.5px; line-height: 1.5;
                 color: var(--c-on-dark-2); }

/* ── right · the action, then the proof ──────────────────────────────────────
 * align-items: center is load-bearing, not tidiness. A column flex container
 * defaults to `stretch`, and a child with `width:100%; max-width:440px` under
 * `stretch` is capped at 440 and then parked at the START of the cross axis —
 * so the card hugged the left edge of its half and the whole page read as
 * off-centre, on a screen whose entire job is one field. */
.gate-panel { padding: clamp(28px, 4.4vw, 60px) clamp(20px, 3.4vw, 48px);
              display: flex; flex-direction: column; gap: 20px;
              align-items: center; justify-content: center; min-width: 0; }

/* MAGI's `.dialog`: 200ms, scale from 0.97, strong ease-out. Left-aligned now —
 * a centred label over a centred field over a centred button is a login form
 * out of a template, and it makes the error line jump about under the input. */
.gate-card { background: var(--c-surface); border: 1px solid var(--c-line);
             border-radius: var(--r-md); padding: 26px 26px 22px;
             width: 100%; max-width: 440px;
             display: flex; flex-direction: column; gap: 10px;
             animation: dialog-in 200ms var(--ease-out) both;
             box-shadow: 0 8px 28px rgba(27, 26, 22, 0.07); }
.gate-card h1 { font-size: 19px; margin: 0; font-weight: 600;
                letter-spacing: -0.022em; line-height: 1.2; }
.gate-card .sub { color: var(--c-ink-3); font-size: 12px; margin: 0 0 4px;
                  line-height: 1.6; }
.gate-card input { font-size: 15px; padding: 11px 12px; letter-spacing: 0.05em;
                   width: 100%; }
.gate-card button { padding: 11px 14px; font-size: 13px; font-weight: 600; }
/* A failed code is `alarm`, and it carries the solid mark so the state is not
 * a red sentence alone. */
.gate-card .err { color: var(--c-alarm-ink); font-size: 11.5px; min-height: 16px;
                  font-weight: 500; line-height: 1.4; }
.gate-card .err:not(:empty)::before {
  content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 1px;
  margin-right: 5px; background: var(--c-alarm); }
.gate-ask { margin: 6px 0 0; font-size: 11.5px; line-height: 1.6;
            color: var(--c-ink-3); }
.gate-ask a { color: var(--c-ink-2); font-weight: 500;
              text-decoration: underline; text-decoration-style: dotted;
              text-underline-offset: 3px; }
.gate-ask a:hover { color: var(--c-ink); }

/* ── the sample calls ──────────────────────────────────────────────────────
 * Hidden until /showcase.json answers, and each row removes itself if its own
 * audio 404s. A sign-in page that offers a demo and then fails to play it is
 * worse than one that offers nothing, and the upstream here is two object
 * stores we do not own — see the note over SHOWCASE in webphone.py. */
.gate-samples { width: 100%; max-width: 440px; }
.gate-samples[hidden] { display: none; }
.gate-samples-head { margin-bottom: 10px; }
/* The offer, at the weight of a question someone might actually say yes to.
 * The eyebrow above it is the context ("While you wait"); this is the sentence
 * doing the asking, so it gets ink and size instead of being a third grey line. */
.gate-samples-h { margin: 7px 0 0; font-size: 14px; font-weight: 600;
                  letter-spacing: -0.012em; color: var(--c-ink); }
.gate-samples-sub { margin: 4px 0 0; font-size: 11.5px; line-height: 1.6;
                    color: var(--c-ink-3); }
.sc-list { display: flex; flex-direction: column;
           border: 1px solid var(--c-line); border-radius: var(--r-md);
           background: var(--c-surface); overflow: hidden; }
/* cursor: pointer because the WHOLE row is the target — core.js binds the click
 * to the row and lets the button inside it opt out, so the play control was the
 * only part of a 60px-tall strip that admitted to being clickable. */
.sc-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
           grid-template-areas: 'play title meta' 'play blurb blurb';
           align-items: center; gap: 2px 11px; padding: 11px 13px;
           cursor: pointer; }
.sc-item + .sc-item { border-top: 1px solid var(--c-line); }
@media (hover: hover) and (pointer: fine) {
  .sc-item { transition: background-color 90ms ease; }
  .sc-item:hover { background: var(--c-sunk); }
}
/* One circular control per row — the only round thing in the build, and round
 * because every audio transport on earth is. This is the one place where
 * borrowing a convention beats being consistent with ourselves. */
.sc-play { grid-area: play; flex: none; width: 34px; height: 34px;
           border-radius: 50%; padding: 0; display: flex;
           align-items: center; justify-content: center;
           border: 1px solid var(--c-line-strong); background: var(--c-surface);
           color: var(--c-ink); }
.sc-play svg { width: 13px; height: 13px; }
.sc-play .ico-pause { display: none; }
.sc-item.playing .sc-play { border-color: var(--c-ink); background: var(--c-ink);
                            color: var(--c-paper); }
.sc-item.playing .sc-play .ico-play { display: none; }
.sc-item.playing .sc-play .ico-pause { display: block; }
.sc-play:hover { border-color: var(--c-ink-4); }
.sc-title { grid-area: title; font-size: 12.5px; font-weight: 600;
            color: var(--c-ink); letter-spacing: -0.005em; min-width: 0; }
.sc-meta { grid-area: meta; font-size: 10.5px; color: var(--c-ink-3);
           white-space: nowrap; font-variant-numeric: tabular-nums; }
.sc-blurb { grid-area: blurb; margin: 2px 0 0; font-size: 11.5px;
            line-height: 1.5; color: var(--c-ink-3); }
/* The progress line is 2px and runs edge to edge under the row it belongs to.
 * A scrub bar per row would be four controls competing with the one button
 * this page actually wants pressed — press the row, that is the whole
 * interaction. It is a width write on timeupdate and NOT a transition: a bar
 * that eases toward the playhead is a bar lying about where the audio is. */
.sc-prog { grid-column: 1 / -1; height: 2px; margin: 9px -13px -11px;
           background: var(--c-line); display: none; }
.sc-item.playing .sc-prog, .sc-item.played .sc-prog { display: block; }
.sc-prog i { display: block; height: 100%; width: 0%; background: var(--c-ink); }

#log { display: none; }

/* ── notices ───────────────────────────────────────────────────────────────
 * The visible half of core.js's notice(), which until now had none: the
 * function has always opened with `const host = $("toasts"); if (!host)
 * return;` and that element was never in the markup, so every actionable
 * failure in the build took the early return and went nowhere.
 *
 * One stack, bottom-right on a desktop and top-of-screen on a phone where the
 * bottom belongs to the dock. Same two semantics and the same two marks as
 * everywhere else (rule 2): `bad` is broken, `open` is unresolved. */
#toasts { position: fixed; z-index: 200; right: 16px; bottom: 16px;
          display: flex; flex-direction: column; gap: 8px;
          width: min(360px, calc(100vw - 32px)); pointer-events: none; }
.toast { pointer-events: auto; display: grid;
         grid-template-columns: minmax(0, 1fr) auto; gap: 4px 10px;
         background: var(--c-surface); border: 1px solid var(--c-line-strong);
         border-radius: var(--r-md); padding: 11px 12px;
         box-shadow: 0 6px 22px rgba(27, 26, 22, 0.10);
         animation: toast-in 180ms var(--ease-out) both; }
.toast.bad { border-color: var(--c-alarm-line); background: var(--c-alarm-bg); }
.toast.open { border-color: var(--c-open-line); background: var(--c-open-bg); }
.toast-msg { font-size: 12px; line-height: 1.55; color: var(--c-ink-2);
             display: flex; align-items: flex-start; }
.toast.bad .toast-msg { color: var(--c-alarm-ink); }
.toast.open .toast-msg { color: var(--c-open-ink); }
/* the shared marks are inline-block with a baseline nudge; in a flex row they
 * need top alignment instead, so a three-line notice keeps its mark on line one */
.toast-msg::before { margin-top: 4px; }
.toast-x { grid-column: 2; align-self: start; background: none; border: 0;
           color: var(--c-ink-3); font-size: 12px; line-height: 1;
           padding: 2px 4px; }
.toast-x:hover { color: var(--c-ink); background: none; transform: none; }
.toast-act { grid-column: 1 / -1; justify-self: start; margin-top: 4px;
             background: var(--c-surface); color: var(--c-ink);
             border-color: var(--c-line-strong); font-size: 11.5px;
             padding: 6px 10px; }

/* ── boot ──────────────────────────────────────────────────────────────────
 * What #content holds before the first view lands. #content used to be an
 * empty <main>, and everything that fills it is downstream of three round
 * trips — /me, /config, then the view partial — so on anything slower than a
 * laptop on wifi the console opened as a blank white column beside a
 * live-looking rail. That reads as broken, not as loading.
 *
 * The shapes are the shapes of the page that is coming, so nothing jumps when
 * the real thing arrives. NO SHIMMER: a sweeping gradient is ambient motion
 * (rule 3) and it also implies progress the page cannot measure. */
.boot { display: flex; flex-direction: column; gap: 12px; padding-top: 2px; }
.boot-line { background: var(--c-sunk); border-radius: var(--r-xs); }
.boot-h1 { height: 24px; width: min(280px, 60%); }
.boot-p { height: 11px; width: min(520px, 92%); }
.boot-p.short { width: min(380px, 68%); }
.boot-card { height: 210px; margin-top: 6px;
             border: 1px solid var(--c-line); border-radius: var(--r-md);
             background: var(--c-surface); }
/* The failure case, and it is a real one: any of those three round trips can
 * fail, and before this the whole outcome was a white column and no way to
 * find out why. */
.boot-fail { max-width: 60ch; }
.boot-fail h2 { margin: 0 0 6px; font-size: 15px; font-weight: 600;
                letter-spacing: -0.015em; }
.boot-fail p { margin: 0 0 12px; font-size: 12.5px; line-height: 1.6;
               color: var(--c-ink-2); }
.boot-fail .why { font-family: var(--mono); font-size: 11.5px;
                  color: var(--c-ink-3); word-break: break-word; }

/* ── motion ───────────────────────────────────────────────────────────────
 * Everything here is under 210ms and every one of them fires on a real event.
 * There is no page-entrance animation: the shell used to fade-and-rise on
 * every load, which is the console performing itself before it shows you
 * anything. State first, always. */
@keyframes fade-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes msg-in   { from { opacity: 0; transform: translateY(6px); }
                      to   { opacity: 1; transform: none; } }
@keyframes dialog-in{ from { opacity: 0; transform: scale(0.97); }
                      to   { opacity: 1; transform: none; } }
/* A notice arrives from the edge it is docked to, which is the one thing that
 * distinguishes it from content that was always there. */
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); }
                      to   { opacity: 1; transform: none; } }
@keyframes pulse    { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }

/* Reduced motion keeps the opacity cue — that is what tells you a line landed —
 * and drops everything that moves. Fewer and gentler, not zero. */
@media (prefers-reduced-motion: reduce) {
  .msg { animation: fade-in 160ms var(--ease-out) both; }
  .gate-card { animation: fade-in 160ms var(--ease-out) both; }
  .toast { animation: fade-in 160ms var(--ease-out) both; }
  /* The cat stays — it is what says "asleep" — it just stops moving. The Zzz
   * are held visible rather than dropped, so the state still reads. */
  #btn.asleep .cat-body { animation: none; }
  #btn.asleep .cat-z text { animation: none; opacity: 0.75; }
  /* The poses stay, the motion goes — every one of these cats is carrying a
     state, so removing the drawing would remove the information with it. The
     waiting cat simply sits still. The stretch is skipped in JS before it is
     ever inserted (see _catStretch in index.html); this covers the person who
     turns the preference on while one is mid-flight. */
  #btn.queued .cat-tail { animation: none; }
  .btn-cat-stretch { animation: none; }
  button:active { transform: none; }
  button { transition: background-color 90ms ease; }
  #speaking::after { animation: none; opacity: 0.7; }
  .capbar i { transition: none; }
}

/* ── phone chrome · declared once, drawn only under 760px ─────────────────
 * The title bar, the call dock, the tab bar, the setup sheet and the pane's
 * close button are in the markup on every viewport and are hidden here, so
 * "these five are not part of the desktop instrument" is one statement in one
 * place rather than five overrides scattered through the media queries. */
.mtop, .dock, .tabbar, .rail-x, dialog.sheet { display: none; }

/* ── responsive ───────────────────────────────────────── */
@media (max-width: 1240px) {
  .app { grid-template-columns: 196px minmax(0, 1fr) 352px; }
}
@media (max-width: 1080px) {
  .app { grid-template-columns: 1fr; }
  .nav { position: static; height: auto; flex-direction: row; align-items: center;
         flex-wrap: wrap; gap: 10px; padding: 10px 12px; }
  .logo { padding: 0 8px 0 0; }
  .nav-sec { display: none; }
  /* THREE .nav-items groups now, laid side by side in one row with their
     headings dropped — so `flex: 1` each would hand a third of the bar to the
     Admin group, which is empty for every customer. Content-width instead, and
     the LAST group takes the slack so .nav-foot still ends up hard right.
     `:last-of-type` rather than a class because that is exactly the rule: the
     final group, whichever section it turns out to be. */
  .nav-items { flex-direction: row; flex: 0 0 auto; }
  .nav-items:last-of-type { flex: 1 1 auto; }
  .nav-item { width: auto; padding: 7px 10px; }
  /* The nav is one row of labels here, and the badge is the widest thing on
     the item it sits on. The nav item still says "Text to Speech"; the "new"
     is a rail affordance and this is no longer the rail. */
  .nav-new { display: none; }
  .nav-item.on::before { display: none; }
  .nav-item.on { border-color: var(--c-on-dark-3); }
  .nav-foot { margin: 0; flex-direction: row; align-items: center; border-top: none;
              padding: 0; gap: 10px; }
  .nav-cap { min-width: 130px; }
  .rail { position: static; height: auto; }
  #chat { max-height: 340px; }
}
@media (max-width: 900px) {
  .home-steps { grid-template-columns: 1fr; }
}

/* ── the gate, as ONE COLUMN ────────────────────────────────────────────────
 * Under 940px the two halves become a single column, and the pieces reorder
 * into what a phone sign-in actually is:
 *
 *   ┌──────────────────────┐
 *   │  ▓▓ hero photograph  │  the mark and one line of pitch, over the image.
 *   │  ✳ Voice Infra       │  A phone expects the brand at the top, and this
 *   │  ₹1 a minute…        │  is the only thing on the page that is a picture.
 *   ├──────────────────────┤
 *   │  Open your sandbox   │  the field, above the fold, full width
 *   │  [ access code     ] │
 *   │  [ Enter           ] │
 *   ├──────────────────────┤
 *   │  While you wait      │  the offer, one thumb-flick down
 *   │  ▸ Cart recovery…    │
 *   ├──────────────────────┤
 *   │  A note from the …   │  the essay, last, where it costs nobody anything
 *   └──────────────────────┘
 *
 * The stacked version used to be the desktop grid with `order` flipped, which
 * put a screen and a half of founder's note in front of someone holding a code
 * and left the photograph off the phone entirely. `display: contents` on
 * .gate-left is what makes the three real siblings, so each can be placed on
 * its own — the hero takes the image with it, and the note keeps the dark
 * surface without pretending to be a column. */
@media (max-width: 940px) {
  .gate-grid { display: flex; flex-direction: column;
               grid-template-columns: none; min-height: 0; }
  /* The wrapper stops being a box; its two children become flex items here.
     `content: none` is NOT optional. A `display: contents` element generates no
     box of its own, but browsers still generate its ::before — and that one is
     `position: absolute; inset: 0`, so with its parent gone it resolves against
     #gate (the nearest positioned ancestor, and a fixed full-screen one) and
     lays the dark scrim over the ENTIRE page. The symptom is the code card and
     the samples sitting on grey instead of paper. */
  .gate-left { display: contents; }
  .gate-left::before { content: none; display: none; }

  .gate-hero { position: relative; isolation: isolate; order: 0;
               background-color: var(--c-dark); color: var(--c-on-dark);
               background-image: url('/static/hero-login.jpg');
               background-size: cover; background-position: 50% 34%;
               /* Tall enough for the photograph to read as a photograph and
                * short enough that the code field is still on screen under it.
                * Capped in px as well as vh: on a landscape phone 34vh is a
                * strip, and on a tablet it is half the screen. */
               min-height: clamp(210px, 34vh, 300px);
               justify-content: flex-end; gap: 16px;
               padding: calc(env(safe-area-inset-top) + 26px)
                        20px 24px; }
  /* The scrim follows the photograph. Up there it belongs to .gate-left, the
     box that carries the image on a desktop; down here .gate-left generates no
     box at all and .gate-hero carries it instead, so the identical gradient is
     declared against the hero. Two scopes, one at a time — see the note over
     .gate-left::before for what a single shared selector did to the desktop. */
  .gate-hero::before { content: ''; position: absolute; inset: 0; z-index: -1;
    background:
      linear-gradient(to bottom,
        rgba(21, 21, 19, 0.62) 0%,
        rgba(21, 21, 19, 0.28) 38%,
        rgba(21, 21, 19, 0.46) 66%,
        rgba(21, 21, 19, 0.90) 100%),
      linear-gradient(to right,
        rgba(21, 21, 19, 0.62) 0%,
        rgba(21, 21, 19, 0.26) 62%,
        rgba(21, 21, 19, 0.10) 100%); }
  .gate-hero > * { position: relative; }
  .gate-lede { font-size: clamp(18px, 4.6vw, 23px); max-width: 22ch; }

  .gate-panel { order: 1; align-items: center; width: 100%;
                padding: 22px 16px 6px; }

  .gate-story { order: 2; position: relative; isolation: isolate;
                background: var(--c-dark); color: var(--c-on-dark);
                border-right: 0; margin-top: 22px;
                padding: 26px 20px calc(26px + env(safe-area-inset-bottom));
                gap: 22px; }
  .gate-story > * { width: 100%; max-width: 480px; margin-inline: auto; }
  .gate-note { padding-top: 0; border-top: 0; max-width: 480px; }
  .gate-facts { grid-template-columns: 1fr; gap: 12px; }
  .gate-facts > div { display: flex; align-items: baseline; gap: 10px; }
  .gate-facts dt { flex: 0 0 auto; }
  .gate-facts dd { margin: 0; }
}

/* Landscape phone: the photograph yields to the field. 34vh of a 390px-tall
 * viewport is only 133px, so the clamp floor of 210px wins and the hero takes a
 * fixed 210 out of a screen that has to hold the lede, the card, the input and
 * the Enter button — which lands below the fold. The fold is the constraint
 * that wins on this page: someone is holding a code and looking for somewhere
 * to type it, and a hero they have to scroll past is a hero that has failed.
 *
 * MIN-HEIGHT WAS ONLY A FLOOR, which is why lowering it on its own did nothing:
 * measured at 844×390 the hero still came out 258px tall, because its CONTENT
 * is what sets the height there — 50px of padding, a 40px brand row, a 16px
 * gap, and a 23px lede wrapping to four lines inside a 22ch measure. No floor
 * can shrink a box that is already taller than it. So the content is what
 * yields: the padding halves, the mark comes down to 24px, and the lede drops
 * to 15px over a WIDER measure.
 *
 * The wider measure is the counter-intuitive half. 22ch is right on a portrait
 * phone, where the screen is narrow and a long line has nowhere to go. Landscape
 * is the opposite shape — 844 across and 390 tall — so the constraint moves from
 * width to height, and the same sentence set at 52ch lands on two lines instead
 * of five. Every line saved is a line the Enter button moves up, which is the
 * only thing this breakpoint is for.
 *
 * 52ch and not more: 48 is the narrowest cap that still wraps the current pitch
 * to two lines, and everything from 48 to 72 measures identically because the
 * TEXT is what sets the line, not the cap. The four characters of slack are for
 * the copy changing; going wider than that would buy nothing and only leave room
 * for a future sentence to run at a measure nobody wants to read.
 *
 * Measured at 844×390: hero 258px → 114px, and the Enter button's bottom edge
 * from 463px (73px past the fold) to 345px, which leaves room for the browser
 * chrome a real phone has and this viewport does not. */
@media (max-width: 940px) and (max-height: 520px) {
  .gate-hero { min-height: 0; gap: 8px;
               padding-top: calc(env(safe-area-inset-top) + 14px);
               padding-bottom: 14px; }
  .gate-hero .gate-logo { height: 24px; }
  .gate-hero .gate-lede { font-size: 15px; line-height: 1.3; max-width: 52ch; }
}

/* ═══════════════════════════════════════════════════════════════════════════
 * PHONE — a layout, not a reflow.
 *
 * Above 760px this file draws a three-column instrument. Below it the same
 * markup is a phone app, and the difference is structural rather than a
 * narrower version of the same page:
 *
 *   ┌───────────────────────────┐  title bar   — brand + the setup sheet
 *   │ ✳ Voice Infra          ⚙ │                (fixed, dark, safe-area top)
 *   ├───────────────────────────┤
 *   │                           │  #content    — the ONLY thing that scrolls
 *   │   the view                │
 *   │   (the call pane covers    │  .rail      — transcript + turn chart,
 *   │    this while it is open) │                an overlay between the bars
 *   ├───────────────────────────┤
 *   │ ⌃ Live transcript  in call│  the dock    — handle + status
 *   │ [  Talk to your agent   ] │                the ONE primary control
 *   │ ▁▁▁▁▁▁ mic level          │                the mic meter, next to it
 *   │ or have it call a phone ⌄ │                the dial group, folded
 *   ├───────────────────────────┤
 *   │  ⌂     ✎     ◷     ⚿      │  tab bar     — thumb reach, safe-area bottom
 *   └───────────────────────────┘
 *
 * THE BODY DOES NOT SCROLL — #content does. That is what stops the URL bar
 * from chasing the dock around, and it is why there is not one `vh` below:
 * the shell is sized from --vvh (the visual viewport, written by core.js when
 * the soft keyboard opens) with 100dvh as the fallback.
 *
 * NOTHING IS REORDERED. The old build used `order:` to float the test rail
 * above the content, which left the keyboard walking the page in a sequence
 * that did not match the screen. The phone-only elements now sit in reading
 * order in the markup and the desktop-only ones are display:none, so tab order
 * is visual order by construction.
 * ═══════════════════════════════════════════════════════════════════════════*/
@media (max-width: 760px) {

  /* ── shell ─────────────────────────────────────────────── */
  html { height: 100%; }
  body { height: var(--vvh, 100dvh); min-height: 0; overflow: hidden;
         display: flex; flex-direction: column;
         padding: env(safe-area-inset-top) env(safe-area-inset-right)
                  0 env(safe-area-inset-left); }
  /* the banner carries the notch inset on desktop; on a phone the body does,
     otherwise the two of them inset the same 47px twice */
  .gpu-banner-in { padding-top: 9px; }
  #gpu-status-banner { position: static; flex: 0 0 auto; }
  /* Same reason as the banner above it: the phone shell is a fixed-height flex
     column, so a sticky bar inside it has nothing to stick to. It is a row. */
  .impbar { position: static; flex: 0 0 auto; padding: 7px 14px; }
  .gpu-banner-row { padding: 0 14px; font-size: 11.5px; }

  /* align-items:start is the desktop grid's — carried into a column flex
     container it becomes the CROSS axis and every bar shrinks to its own text */
  .app { flex: 1 1 auto; min-height: 0; position: relative;
         display: flex; flex-direction: column; align-items: stretch;
         max-width: none; margin: 0; padding: 0; gap: 0; }
  .nav { display: none; }

  /* ── title bar ─────────────────────────────────────────── */
  /* Dark, because the nav it replaces is the one dark surface in the build and
     <meta name="theme-color"> is already that value — the iOS status bar and
     this bar are then literally the same colour with no seam. */
  .mtop { display: flex; align-items: center; gap: 9px; flex: 0 0 auto;
          height: var(--mtop-h); padding: 0 4px 0 14px; z-index: 40;
          background: var(--c-dark); color: var(--c-on-dark);
          border-bottom: 1px solid var(--c-dark-line); }
  /* The mark and the wordmark are one button now — the way back to Welcome,
     which no longer has a tab. It has to look exactly like the two static
     elements it replaced: no border, no background, no padding, and the same
     flex behaviour, so the only thing that changed for a customer is that
     tapping it does something. The 44px min-height is the touch target the
     rest of the phone chrome holds to. */
  .mtop-home { display: flex; align-items: center; gap: 9px; flex: 1 1 auto;
               min-width: 0; min-height: 44px; margin: 0; padding: 0;
               background: none; border: 0; text-align: left;
               font: inherit; color: inherit; }
  .mtop-home:active { opacity: 0.6; }
  .mtop-home:hover { background: none; transform: none; }
  .mtop-mark { height: 19px; width: auto; flex: none; display: block; }
  .mtop-title { flex: 1 1 auto; font-size: 14px; font-weight: 600;
                letter-spacing: -0.02em; }
  .mtop-btn { width: 44px; height: 44px; flex: 0 0 auto; padding: 0;
              display: flex; align-items: center; justify-content: center;
              background: none; border: 0; color: var(--c-on-dark-2); }
  .mtop-btn svg { width: 20px; height: 20px; }
  .mtop-btn:active { color: var(--c-on-dark); transform: none; }
  /* the one ink outline is invisible on the dark bar */
  .mtop-btn:focus-visible { outline-color: var(--c-on-dark); outline-offset: -4px; }

  /* ── the scrolling view ────────────────────────────────── */
  #content { --pad-x: 14px;
             flex: 1 1 auto; min-height: 0; overflow-y: auto;
             overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
             padding: 14px var(--pad-x) 28px; scroll-padding-bottom: 28px; }

  /* ── the call pane ─────────────────────────────────────────────────────
   * The transcript is what you look at during a call, so on a phone it is not
   * a box in a sidebar — it is a surface that covers the view from under the
   * title bar to the top of the dock. #chat is the only thing inside it that
   * scrolls, which is what keeps a 600-character agent turn from pushing the
   * End call button off the bottom of the screen.
   * Closed it is `visibility: hidden`, not translated-away: hidden removes it
   * from the tab order and the accessibility tree, which a transform does not,
   * while still leaving #chat a real height to scroll to the bottom of. */
  .rail { position: absolute; z-index: 20; left: 0; right: 0;
          top: var(--mtop-h); bottom: calc(var(--dock-h) + var(--tabbar-h));
          height: auto; gap: 6px; padding: 0 12px 8px;
          background: var(--c-paper); border-top: 1px solid var(--c-line);
          overflow: hidden;
          visibility: hidden; pointer-events: none;
          opacity: 0; transform: translateY(10px);
          transition: opacity 170ms var(--ease-out),
                      transform 170ms var(--ease-out),
                      visibility 0s linear 170ms; }
  .app.callopen .rail { visibility: visible; pointer-events: auto;
                        opacity: 1; transform: none; transition-delay: 0s; }

  .rail-head { display: grid; grid-template-columns: minmax(0, 1fr) auto;
               align-items: center; gap: 2px 8px; padding: 6px 0 2px;
               flex: 0 0 auto; }
  .rail-head h2 { grid-column: 1; font-size: 14px; }
  #testctx { grid-column: 1; justify-self: start; }
  .rail-x { display: flex; align-items: center; justify-content: center;
            grid-column: 2; grid-row: 1 / span 2;
            width: 44px; height: 44px; margin-right: -10px; padding: 0;
            background: none; border: 0; color: var(--c-ink-3); }
  .rail-x svg { width: 18px; height: 18px; }
  .rail-x:active { color: var(--c-ink); transform: none; }

  /* basis 0, not auto: with `auto` the transcript's own content height enters
     the flex negotiation and a long call squeezes the chart out of its natural
     size. The transcript scrolls, so what it wants is irrelevant — it takes
     whatever is left after the chart, and never less than four lines. */
  #chatpane { flex: 1 1 0; min-height: 110px; }
  #chatpane h2 { display: none; }      /* the pane header above already names it */
  #chat { max-height: none; padding: 10px 10px 12px; gap: 9px;
          overscroll-behavior: contain; }
  /* a transcript is prose being read on a phone, not a data cell */
  .msg { max-width: 90%; font-size: 14px; }
  .sys { font-size: 12px; }
  #speaking { font-size: 12.5px; }

  /* ── the turn chart, as cards that snap ────────────────────────────────
   * A 52px label + a bar + a 58px figure in a 340px row leaves the bar too
   * short to read a threshold off. So on a phone each turn becomes a card that
   * fills most of the screen and the strip snaps one turn at a time, which is
   * also the only honest way to scroll a chart with a finger. The axis is
   * hidden: it spans the pane, the cards do not, and an axis whose 1 s mark
   * does not sit over the bars' 1 s tick is worse than no axis at all — the
   * ticks still cross every bar, and the legend still names the three states. */
  /* The chart may not eat the transcript. It takes what it needs up to 46% of
     the pane and scrolls inside that; the transcript keeps the rest and never
     drops under 110px, which is three lines and the newest one. */
  .latwrap { flex: 0 1 auto; max-height: min(52%, 268px); overflow-y: auto;
             overscroll-behavior: contain; padding: 0 0 2px; }
  .latwrap summary { display: flex; align-items: center; min-height: 44px;
                     padding: 4px 12px; }
  #latpane.has-data .lat-axis { display: none; }
  .lat-sum { padding: 0 12px 8px; }
  #lat { display: flex; gap: 8px; overflow-x: auto; overflow-y: hidden;
         padding: 0 12px 10px; scroll-snap-type: x mandatory;
         scroll-padding-left: 12px; overscroll-behavior-x: contain; }
  .lat-row { flex: 0 0 min(70%, 230px); scroll-snap-align: start;
             grid-template-columns: minmax(0, 1fr) auto;
             grid-template-areas: 'n v' 't t'; gap: 7px 8px;
             padding: 9px 10px; border: 1px solid var(--c-line);
             border-radius: var(--r-md); background: var(--c-surface); }
  .lat-n { grid-area: n; }
  .lat-v { grid-area: v; }
  .lat-track { grid-area: t; height: 16px; }
  .lat-empty { flex: 1 1 auto; padding: 2px 0 8px; }
  .legend { padding: 2px 12px 10px; line-height: 1.85; }

  /* ── the dock ──────────────────────────────────────────────────────────
   * One control is what this product is for, and on a phone it is never more
   * than a thumb away: docked above the tab bar, full width, with the mic
   * level directly under it instead of in a sidebar the phone does not have.
   * #railcard is moved in here by core.js at this breakpoint and moved back
   * into the rail above it, so there is exactly one of each control. */
  .dock { display: flex; flex-direction: column; gap: 6px; flex: 0 0 auto;
          z-index: 30; background: var(--c-surface);
          border-top: 1px solid var(--c-line); padding: 2px 12px 10px; }
  .dock-grip { display: flex; align-items: center; gap: 10px; min-height: 44px; }
  .dock-handle { flex: 1 1 auto; display: flex; align-items: center; gap: 7px;
                 justify-content: flex-start; min-height: 44px; padding: 0 2px;
                 background: none; border: 0; color: var(--c-ink-2);
                 font-size: 12px; font-weight: 500; }
  .dock-handle svg { width: 15px; height: 15px; flex: 0 0 auto;
                     transition: transform 170ms var(--ease-out); }
  .app.callopen .dock-handle svg { transform: rotate(180deg); }
  .dh-open { display: none; }
  .app.callopen .dh-closed { display: none; }
  .app.callopen .dh-open { display: inline; }
  .dock-handle:active { color: var(--c-ink); transform: none; }
  .dock #status { flex: 0 1 auto; min-height: 0; padding: 0; text-align: right;
                  font-size: 11.5px; white-space: nowrap; overflow: hidden;
                  text-overflow: ellipsis; }
  .dock .rail-card { border: 0; background: none; padding: 0; border-radius: 0; }
  .dock .rail-cta { gap: 6px; }
  .dock #btn { min-height: 52px; font-weight: 600; }
  .dock #meter { height: 5px; }
  .dock .rail-hint { display: none; }  /* said once, at the first call, not forever */
  /* The switch sits above the button, thumb-sized, and both panels are
   * borderless down here — the dock already is the surface. */
  .dock .howrow { margin: 0 0 8px; }
  .dock .howtab { min-height: 44px; font-size: 12px; }
  .dock #cta-web, .dock #dialbody { border: 0; border-radius: 0;
                                    background: none; padding: 0; }
  .dock .action-row { margin-top: 0; }
  .dock input#phone { width: 100%; flex: 1 1 100%; }
  .dock #dialbtn { flex: 1 1 100%; }
  .dock .linkbtn { min-height: 44px; padding: 4px 0 0; }

  /* ── the tab bar ───────────────────────────────────────────────────────
   * Same buttons and the same navTo() as the desktop nav. Selected carries a
   * 3px top rule (the same 3px edge the desktop nav uses on its left), a
   * weight change and ink colour — three axes, so the state survives greyscale
   * and a deuteranope reads it off the rule, per rule 2. */
  .tabbar { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
            flex: 0 0 auto; z-index: 30; background: var(--c-surface);
            border-top: 1px solid var(--c-line);
            padding-bottom: env(safe-area-inset-bottom); }
  .tabbar .nav-item { flex-direction: column; justify-content: center;
                      align-items: center; gap: 4px; width: auto;
                      min-height: 54px; padding: 8px 4px; text-align: center;
                      border: 0; border-radius: 0; color: var(--c-ink-3);
                      font-size: 10.5px; font-weight: 500; letter-spacing: 0.01em;
                      line-height: 1; }
  .tabbar .nav-item svg { width: 21px; height: 21px; opacity: 1; }
  .tabbar .nav-item:active { background: var(--c-sunk); transform: none; }
  .tabbar .nav-item.on { background: none; color: var(--c-ink); font-weight: 600; }
  .tabbar .nav-item.on svg { stroke-width: 2.1; }
  .tabbar .nav-item.on::before { content: ''; position: absolute;
    top: 0; bottom: auto; left: 50%; transform: translateX(-50%);
    width: 28px; height: 3px; background: var(--c-ink); }

  /* ── the setup sheet ───────────────────────────────────────────────────
   * A real <dialog> opened with showModal(): focus trap, Escape, scrim and an
   * inert page, all from the platform. The element is full-screen and the
   * panel is the bottom of it, so a tap on the dimmed area lands on the
   * <dialog> itself and core.js closes it.
   *
   * THREE THINGS ANCHOR IT, and it took losing all three on an iPhone to learn
   * that any one of them alone is a guess:
   *
   *   height: var(--vvh) — NOT 100%. A fixed element's percentage height comes
   *     off the layout viewport, which on iOS is the tall one, the one that
   *     includes the strip the URL bar sits over. The dialog was therefore
   *     taller than the screen, its bottom edge was under the browser chrome,
   *     and the panel anchored to an edge nobody can see. Everything else in
   *     this shell is sized from --vvh; so is this.
   *   a COLUMN with justify-content: flex-end — `align-items: flex-end` on a
   *     default-row container puts the panel at the bottom by way of the cross
   *     axis, which is one `flex-direction` away from silently becoming a
   *     horizontal alignment. Bottom-anchoring should be said on the main axis.
   *   z-index — core.js falls back to setAttribute("open") where showModal is
   *     missing, and a dialog outside the top layer is just a fixed box that
   *     the dock and the tab bar (both z-index 30) paint straight over. 60
   *     clears them and the title bar (40). */
  dialog.sheet { display: none; position: fixed; inset: 0; overflow: hidden;
                 width: 100%; max-width: none;
                 height: var(--vvh, 100dvh); max-height: none;
                 margin: 0; padding: 0; border: 0; background: transparent;
                 z-index: 60; flex-direction: column;
                 justify-content: flex-end; align-items: stretch; }
  dialog.sheet[open] { display: flex; }
  dialog.sheet::backdrop { background: var(--c-scrim); }
  .sheet-panel { width: 100%; max-height: 88%; overflow-y: auto;
                 overscroll-behavior: contain;
                 background: var(--c-surface);
                 border-top: 1px solid var(--c-line);
                 border-radius: var(--r-md) var(--r-md) 0 0;
                 padding: 6px 16px calc(16px + env(safe-area-inset-bottom));
                 animation: sheet-in 210ms var(--ease-out) both; }
  /* the panel is the dialog's focus parking spot, not a control */
  .sheet-panel:focus, .sheet-panel:focus-visible { outline: none; }
  .sheet-grab { width: 36px; height: 4px; border-radius: var(--r-xs);
                background: var(--c-line-strong); margin: 4px auto 8px; }
  .sheet-head { display: flex; align-items: center; justify-content: space-between;
                gap: 10px; }
  .sheet-head h2 { margin: 0; font-size: 15px; font-weight: 600;
                   letter-spacing: -0.01em; color: var(--c-ink); }
  .sheet-x { width: 44px; height: 44px; margin-right: -10px; padding: 0;
             display: flex; align-items: center; justify-content: center;
             background: none; border: 0; color: var(--c-ink-3); }
  .sheet-x svg { width: 18px; height: 18px; }
  .sheet-x:active { color: var(--c-ink); transform: none; }
  .sheet-body { display: flex; flex-direction: column; }
  .sheet-body .rail-field { padding: 12px 0; min-height: 56px;
                            border-bottom: 1px solid var(--c-line); }
  /* same reasoning as the rail's 66% — agent names are the longest values here,
     and a sheet is the narrowest place one has to fit */
  .sheet-body .rail-field select { max-width: 62%; }
  /* The nav footer is drawn for the dark rail. In a paper sheet every one of
     its values has to come back to the paper side of the same palette — this
     is the port, not a second theme. */
  /* the 1080px rule laid this out as a row for the horizontal nav bar; in a
     sheet it is a stack again */
  .sheet-body .nav-foot { display: flex; flex-direction: column;
                          align-items: stretch; margin: 0; gap: 14px;
                          padding: 14px 0 0; border-top: 1px solid var(--c-line); }
  .sheet-body .nav-cap { display: flex; padding: 0; min-width: 0; }
  .sheet-body .nav-cap-row { color: var(--c-ink-3); }
  .sheet-body .nav-cap-row b { color: var(--c-ink); }
  .sheet-body .nav-cap-row b.full { color: var(--c-alarm-ink); }
  .sheet-body .nav-cap-row b.full::before { background: var(--c-alarm); }
  .sheet-body .nav-cap-row.sub span:last-child { color: var(--c-ink-2); }
  .sheet-body .capbar { background: var(--c-line); }
  .sheet-body .capbar i { background: var(--c-ink-3); }
  .sheet-body .capbar i.full { background: var(--c-alarm); }
  .sheet-body .meetbtn { color: var(--c-ink); border-color: var(--c-line-strong);
                         min-height: 44px; }
  .sheet-body .meetbtn:active { background: var(--c-sunk); }
  .sheet-body .nav-me { padding: 0; }
  .sheet-body #brandpill { color: var(--accent); border-color: var(--accent-line); }
  .sheet-body .signout { color: var(--c-ink-3); min-height: 44px; padding: 4px 8px; }

  /* ── soft keyboard ─────────────────────────────────────────────────────
   * core.js sets .kbd when the visual viewport shrinks. The tab bar always
   * goes — no native app keeps its tabs up while you type — and the dock goes
   * with it UNLESS the field being typed into is the dock's own number box,
   * which is the one case where hiding it would take away what you are
   * looking at. The metrics zero out with them so the call pane refills the
   * space instead of leaving a hole where the dock was. */
  body.kbd .dock, body.kbd .tabbar, body.kbd-dock .tabbar { display: none; }
  body.kbd { --dock-h: 0px; --tabbar-h: 0px; }
  body.kbd-dock { --tabbar-h: 0px; }

  /* ── the gate ──────────────────────────────────────────────────────────
     The one-column arrangement is set at 940px above; this is only what
     changes at phone size. #gate is the scroller and the body's own overflow
     rule (hidden, for the app shell) must not apply to it — the sign-in page
     is a page you scroll, not a shell with fixed chrome. */
  #gate { height: var(--vvh, 100dvh); bottom: auto; overflow-y: auto;
          padding: 0; }
  .gate-panel { padding: 20px 14px 4px; gap: 16px; }
  .gate-card { max-width: none; padding: 20px 17px; }
  .gate-card h1 { font-size: 18px; }
  .gate-samples { max-width: none; }
  .gate-story { padding-left: 17px; padding-right: 17px; }
  .gate-hero { padding-left: 17px; padding-right: 17px; }
  .gate-logo { height: 32px; }
  /* The phone rules give every button a 44px minimum height, which turned the
     one round control in the build into an ellipse — 34 wide by 44 tall. It
     gets the 44 on BOTH axes instead: a proper thumb target that is still a
     circle. */
  .sc-play { width: 44px; height: 44px; min-height: 44px; }
  .sc-play svg { width: 15px; height: 15px; }
  .sc-item { gap: 3px 12px; padding: 13px; }
  .sc-prog { margin: 10px -13px -13px; }

  /* ── notices, on a phone ───────────────────────────────────────────────
     The bottom is the dock and the tab bar, so a toast anchored there covers
     the one control this product exists for. It comes down from the top
     instead, under the safe-area inset. */
  #toasts { right: 10px; left: 10px; width: auto; bottom: auto;
            top: calc(env(safe-area-inset-top) + 8px); }
  .toast { animation: toast-in-top 180ms var(--ease-out) both; }


  /* ── views ─────────────────────────────────────────────── */
  #campreport { height: calc(var(--vvh) - var(--mtop-h) - var(--tabbar-h) - 28px); }
  /* Same arithmetic as #campreport above, plus the view head this one has and
     that one does not. The chat still owns a screenful; the quickstart below it
     is reached by scrolling #content, exactly as every other view is read. */
  .llm-chatcol { height: calc(var(--vvh) - var(--mtop-h) - var(--tabbar-h) - 108px);
                 min-height: 340px; }
  .llm-chat { padding: 12px; }
  .llm-foot { padding: 9px 12px 12px; }

  .view-head { margin: 0 0 14px; }
  .view-head h1 { font-size: 20px; }
  .block, .card { padding: 14px; }
  .two-col, .admin-grid, .dev-grid { grid-template-columns: 1fr; }
  /* Same reason as the agent chips: on a phone these are the control that
     picks the section, and a 26px chip is not a touch target. */
  .devrow .howtab, .qsrow .howtab { min-height: 44px; }
  /* Three mono labels, one of which is a route, in a 390px viewport: they will
     not fit side by side and `white-space: nowrap` would clip them rather than
     admit it. Stacked, the switch is three full-width rows and each one is a
     44px target. */
  .howrow.qsrow { grid-template-columns: 1fr; }
  .qsrow .howtab { white-space: normal; }
  .qsrow .howtab + .howtab { border-left: 0; border-top: 1px solid var(--c-line); }
  .tts-facts { gap: 10px 18px; }
  .dev-mintrow { flex-direction: column; }
  .hist-stats { grid-template-columns: repeat(2, 1fr); gap: 8px; }
  .script-tools { flex-direction: column; }
  .script-tools select, .script-tools input, .script-tools button {
    width: 100%; min-width: 0; }
  /* ── the agent card, on a phone ────────────────────────────────────────
     The header stops being a row: the agent strip takes the full width and the
     two actions become a row under it with Save carrying its own weight.

     The strip swipes, with the same manners as the home page's cards — it
     bleeds to the card's edges so a chip half off the right side is the thing
     that says there are more, and it snaps so a swipe lands on a chip rather
     than between two. Chips are 44px tall here because they are the primary
     control of this view and a 26px chip is not a touch target. */
  .agent-head { flex-direction: column; align-items: stretch; gap: 10px;
                padding: 12px 14px; }
  .agent-tabs { margin: 0 -14px; padding: 2px 14px 4px;
                scroll-snap-type: x proximity; scroll-padding-left: 14px; }
  .agent-tab, .agent-tab-new { min-height: 44px; padding: 6px 12px;
                               scroll-snap-align: start; }
  .agent-tab { max-width: 62vw; }
  .agent-acts { width: 100%; }
  .agent-acts button.ghost { flex: 1 1 auto; }
  .agent-acts .agent-del { flex: 0 0 auto; padding: 6px 8px; min-height: 44px; }
  .agent-body { padding: 2px 14px 14px; }
  .agent-name { max-width: none; }
  .agent-new { flex-direction: column; align-items: stretch; padding: 12px 14px; }
  .agent-new input, .agent-new button { width: 100%; }
  .docs summary { display: flex; align-items: center; min-height: 44px; }
  .startpoint { padding: 0 14px; }
  .startpoint summary { display: flex; align-items: center; min-height: 44px; }
  /* The circle is the row's only control and it is now the only thing to hit,
     so it takes the full touch target rather than the 34px it draws at on a
     desktop — the same size the gate's sample rows give it. */
  .hist-row { padding: 12px 4px; gap: 0 10px; }
  .hist-play { width: 44px; height: 44px; }
  .hist-play svg { width: 15px; height: 15px; }

  /* The three how-it-works cards were a 3-up grid stacked into a 3-screen
     column on a phone. As a snapping strip they are one screen and one swipe,
     and the strip bleeds to both edges so the second card peeking is the
     affordance — nothing else has to say "scroll me". */
  .home-steps { display: flex; gap: 10px; overflow-x: auto;
                margin: 0 calc(var(--pad-x) * -1);
                padding: 2px var(--pad-x) 8px;
                scroll-snap-type: x mandatory;
                scroll-padding-left: var(--pad-x);
                overscroll-behavior-x: contain; }
  .how { flex: 0 0 78%; scroll-snap-align: start; }

  /* ── touch + input ─────────────────────────────────────── */
  /* 16px, not 15px: iOS zooms any focused field under 16px, which is the whole
   * reason this rule exists. The editor classes are listed explicitly because a
   * class beats a bare element selector and would otherwise reinstate the zoom. */
  button, select, input[type=text], input[type=tel], textarea,
  textarea.editor-lg, textarea.editor-sm { font-size: 16px; }
  button { min-height: 44px; }
  select, input[type=text], input[type=tel] { min-height: 44px; }
  /* the grey flash lands on the wrong box on every one of these */
  a, button, summary, .nav-item, .chip { -webkit-tap-highlight-color: transparent; }
  /* the phone is the one place a 17-line box is a wall — it still grows, and
     agent.js caps it at 42% of the viewport instead of 62% */
  textarea.editor-lg { min-height: 180px; }
}

/* A 5.4" screen (568–640 tall) has ~290px between the chrome once the dock is
 * up, so the chrome gives some back: 4px off the title bar, 4px off each tab,
 * a shorter primary button. Never off the 44px hit areas. */
@media (max-width: 760px) and (max-height: 660px) {
  :root { --mtop-h: 42px; --tabbar-h: calc(50px + env(safe-area-inset-bottom)); }
  .tabbar .nav-item { min-height: 50px; padding: 6px 4px; }
  .tabbar .nav-item svg { width: 19px; height: 19px; }
  .dock { gap: 4px; padding-bottom: 7px; }
  .dock #btn { min-height: 48px; }
  .latwrap { max-height: 40%; }
  #chatpane { min-height: 96px; }
  #content { padding-top: 12px; }
  .view-head { margin-bottom: 12px; }
}

@media (max-width: 430px) {
  #content { --pad-x: 12px; }
  #latpane { --lat-lead: 38px; --lat-tail: 52px; }
  .lat-sum, .legend { padding-left: 12px; padding-right: 12px; }
  .view-head h1 { font-size: 19px; }
  .block, .card { padding: 12px; }
  .docs { padding: 2px 14px 4px; }
  .home-steps { gap: 8px; }
  .how { padding: 12px 14px; flex-basis: 82%; }
  .mtop { padding-left: 12px; }
  .dock { padding-left: 10px; padding-right: 10px; }
  .tabbar .nav-item { font-size: 10px; }
}

/* The sheet arriving is a thing that just happened — a tap — so it is allowed
 * to move (rule 3). Everything else phone-side settles with opacity only. */
@keyframes sheet-in { from { transform: translateY(100%); }
                      to   { transform: none; } }
/* Same 8px as toast-in, from the edge the phone stack is docked to. */
@keyframes toast-in-top { from { opacity: 0; transform: translateY(-8px); }
                          to   { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
  .rail, .dock-handle svg, .rail-div-chev { transition: none; }
  .sheet-panel { animation: fade-in 160ms var(--ease-out) both; }
}

/* ── call playback ─────────────────────────────────────────────────────────
   Listening back to a call, with the transcript running beside the audio.
   Sits directly under the history row it belongs to, so the list stays the
   thing you navigate and the player is an expansion of one entry rather than
   a separate place you go. */
.player-wrap { padding: 0 4px 12px; }
.player {
  border: 1px solid var(--c-line); border-radius: 10px;
  background: var(--c-surface); overflow: hidden;
}
.pl-bar {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 12px; border-bottom: 1px solid var(--c-line);
  background: var(--c-sunk);
}
.pl-play {
  flex: none; width: 34px; height: 34px; border-radius: 50%;
  border: 1px solid var(--c-line-strong); background: var(--c-surface);
  color: var(--c-ink); font-size: 12px; line-height: 1; cursor: pointer;
}
.pl-play:hover:not(:disabled) { background: var(--accent-bg); border-color: var(--accent-line); }
.pl-play:disabled { opacity: .45; cursor: not-allowed; }
.pl-time, .pl-dur {
  flex: none; font-variant-numeric: tabular-nums; font-size: 12px;
  color: var(--c-ink-soft, #6b6862); min-width: 34px;
}
.pl-dur { text-align: right; }
.pl-seek { flex: 1; min-width: 60px; accent-color: var(--accent); cursor: pointer; }
.pl-rate {
  flex: none; min-width: 42px; padding: 4px 8px; cursor: pointer;
  border: 1px solid var(--c-line-strong); border-radius: 6px;
  background: var(--c-surface); color: var(--c-ink);
  font-variant-numeric: tabular-nums; font-size: 12px;
}
.pl-rate:hover { background: var(--accent-bg); border-color: var(--accent-line); }
.pl-dl { flex: none; font-size: 12px; }

/* The lyrics pane. Bounded height so the page does not grow by the length of
   the call, and scrollable on its own. */
.pl-lyrics { max-height: 320px; overflow-y: auto; padding: 6px 0; outline: none; }
.pl-lyrics:focus-visible { box-shadow: inset 0 0 0 2px var(--accent-line); }
.pl-note { padding: 14px 16px; color: var(--c-ink-soft, #6b6862); font-size: 13px; }
.pl-line {
  display: grid; grid-template-columns: 58px 1fr auto; gap: 10px;
  align-items: baseline; padding: 7px 14px; cursor: pointer;
  border-left: 3px solid transparent;
  /* Unspoken lines sit back so the current one reads as the live position
     without the rest becoming unreadable. */
  opacity: .58; transition: opacity .18s var(--ease-out), background .18s var(--ease-out);
}
.pl-line:hover { background: var(--c-sunk); opacity: .85; }
.pl-line.on { opacity: 1; background: var(--accent-bg); border-left-color: var(--accent); }
.pl-who { font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
          color: var(--c-ink-soft, #6b6862); }
.pl-line.bot .pl-who { color: var(--accent); }
.pl-said { line-height: 1.5; }
.pl-at { font-size: 11px; font-variant-numeric: tabular-nums;
         color: var(--c-ink-soft, #6b6862); }

/* The call's files, inside the player they belong to. This was a button in the
 * history row that fetched, renamed itself "preparing… (3/10)" and then turned
 * into a row of links — three different controls in the same eight pixels. As
 * a line under the transport it is just a fact about the call, and it only
 * appears once there is something in it. */
.pl-artifacts { display: flex; align-items: baseline; gap: 10px;
                flex-wrap: wrap; padding: 8px 12px;
                border-bottom: 1px solid var(--c-line);
                background: var(--c-surface); font-size: 11.5px; }
.pl-artifacts-k { color: var(--c-ink-3); font-size: 10.5px; font-weight: 600;
                  letter-spacing: 0.06em; text-transform: uppercase; }
.pl-artifacts a { color: var(--c-ink-2); font-weight: 500;
                  text-decoration: underline; text-decoration-style: dotted;
                  text-underline-offset: 3px; }
.pl-artifacts a:hover { color: var(--c-ink); }

@media (prefers-reduced-motion: reduce) {
  .pl-line { transition: none; }
}
@media (max-width: 640px) {
  .pl-line { grid-template-columns: 1fr auto; }
  .pl-who { grid-column: 1 / -1; }
  .pl-lyrics { max-height: 260px; }
  .pl-dl { display: none; }   /* the artifacts line already offers it */
}

/* ── campaigns view ────────────────────────────────────────────────────────
 * A list, one campaign, and the do-not-call list — three panes in one view,
 * because they are one navigation and the router has no URL for a sub-page.
 *
 * Rows are grids of spans, not a <table>, for the same reason every other list
 * in this build is: the phone folds them into two lines and a table cannot be
 * folded. The one place a table's manners are kept is the header strip, which
 * shares the row's grid so the columns actually line up.
 *
 * NO NEW COLOURS. The segmented progress bar is the only thing here that
 * needed a palette, and it is built out of the tokens already in this file —
 * accent for the calls that landed, the two semantic colours for the ones that
 * did not, and the greys for everything still in flight. Every segment is
 * named in the legend underneath (rule 2: a hue never carries a fact alone). */

.cmp-bar { margin-bottom: 12px; }
.cmp-off h3 { font-size: 14px; }
.cmp-off h3.mark-open::before { vertical-align: -1px; }
.cmp-empty { color: var(--c-ink-3); font-size: 12.5px; padding: 16px 4px; }

/* ── the list ───────────────────────────────────────── */
.cmp-rowhead, .cmp-row {
  display: grid; grid-template-columns: minmax(0, 1fr) 110px 110px 108px;
  align-items: center; gap: 0 12px;
}
.cmp-rowhead { padding: 0 4px 8px; font-size: 10px; letter-spacing: 0.08em;
               text-transform: uppercase; color: var(--c-ink-3);
               font-weight: 600; border-bottom: 1px solid var(--c-line); }
.cmp-row { padding: 11px 4px; font-size: 12px; cursor: pointer;
           border-bottom: 1px solid var(--c-line);
           transition: background-color 90ms ease, box-shadow 90ms ease; }
.cmp-row:last-child { border-bottom: none; }
/* A row you can open says so with a fill AND a line, which is how depth is
 * drawn everywhere else in this build (rule 1) — never with a shadow and never
 * with a shift, because a row that moves under the cursor moves the thing you
 * were about to click. The contacts rows cancel both: they are not clickable
 * and an affordance that lies is worse than none. */
@media (hover: hover) and (pointer: fine) {
  .cmp-row:hover { background: var(--c-sunk);
                   box-shadow: inset 2px 0 0 var(--c-line-strong); }
}
.cmp-row:focus-visible { outline: 2px solid var(--c-ink); outline-offset: -2px; }
.cmp-c-name { font-weight: 600; color: var(--c-ink); min-width: 0;
              overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The name cell is a stack too, and only on an admin's list: under the campaign
 * name, whose it is. Empty on every customer's own page — the server sends no
 * brand there — and `:empty` collapses it, so their rows are the height they
 * always were. */
.cmp-c-title { display: block; overflow: hidden; text-overflow: ellipsis;
               white-space: nowrap; }
.cmp-c-who:empty { display: none; }
.cmp-c-who { display: block; font-style: normal; font-weight: 500;
             font-size: 10.5px; line-height: 1.3; color: var(--c-ink-3);
             overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The status cell is a stack: the pill, and under it the two-word reason a
 * paused campaign is paused. Nothing else ever fills the second line, so the
 * row height is unchanged for every campaign that is not paused. */
.cmp-c-state { justify-self: start; min-width: 0; display: flex;
               flex-direction: column; align-items: flex-start; gap: 3px; }
/* display:none when empty, not merely unstyled — the flex `gap` above applies
 * between items whether or not the second one has anything in it, and 3px on
 * every unpaused row is 3px of nothing. */
.cmp-c-why:empty { display: none; }
.cmp-c-why { font-size: 10.5px; line-height: 1.2; color: var(--c-open-ink); }
.cmp-c-when { color: var(--c-ink-3); }
/* The progress cell is a stack too: the two numbers, and under them the shape
 * of those two numbers. The text is the fact and the bar is the glance — twenty
 * rows of "8,412 / 20,000" is twenty small divisions to do by eye, and the one
 * campaign that has barely started should be findable without doing any of
 * them. One segment on the same track and in the same accent the detail pane's
 * bar gives a completed call, so the vocabulary is learned once. */
.cmp-c-prog { min-width: 0; display: flex; flex-direction: column;
              align-items: stretch; gap: 4px; }
.cmp-c-progn { color: var(--c-ink-3); font-weight: 500; }
.cmp-pager { display: flex; justify-content: center; padding: 10px 0 0; }

/* ── one campaign ───────────────────────────────────── */
.cmp-back { padding-left: 0; margin-bottom: 6px; }
.cmp-headtop { display: flex; align-items: center; justify-content: space-between;
               gap: 10px; margin-bottom: 12px; }
.cmp-name { margin: 0; font-size: 17px; font-weight: 600;
            letter-spacing: -0.02em; color: var(--c-ink); min-width: 0;
            overflow-wrap: anywhere; }
.cmp-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
             gap: 0 18px; }
.cmp-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
/* Stopping is one-way, so the control that does it looks like the alarm it
 * is — but only on hover, because a red button sitting there permanently is
 * one people stop seeing. */
.cmp-stop:hover { color: var(--c-alarm-ink); border-color: var(--c-alarm-line);
                  background: var(--c-alarm-bg); }
.cmp-cardhead { display: flex; align-items: center; justify-content: space-between;
                gap: 10px; margin-bottom: 10px; }

/* The wallet covers part of the list. `open` amber and hatched: unresolved,
 * not broken — the campaign is running, there is simply a decision to make. */
.cmp-fund, .cmp-why {
  margin: 0 0 14px; padding: 11px 12px; border-radius: var(--r-sm);
  background: var(--c-open-bg); border: 1px solid var(--c-open-line);
}
.cmp-fundhead { font-size: 12px; color: var(--c-open-ink); }
.cmp-fund .dev-help { color: var(--c-ink-2); }
/* Why a campaign is paused. Same `open` amber as the funding notice and for
 * the same reason: unresolved, not broken. Paused is a state somebody chose or
 * the money caused, and neither is a fault. */
.cmp-whytext { font-size: 12px; color: var(--c-open-ink); }

/* The tier, while it can still be changed. Sized to sit inside a .dev-fact row
 * without stretching it — the fact rows either side of it must not move when
 * this one turns into a control. */
.cmp-tiersel { max-width: 66%; min-width: 0; font-size: 11.5px;
               padding: 3px 7px; }

/* ── the results card ───────────────────────────────────────────────────────
 * What the campaign DID, between the header and the progress bar. It is the
 * only card on this page a customer opens the page for, and it used to be a
 * ghost button called "Insights" sitting sixth in a row of seven.
 *
 * No new colours and no new shapes: the tiles are the .dev-fact pair — a figure
 * and the word for it — laid on a grid instead of a column, and the strip under
 * them is a list row. Nothing here goes green when the answer is good; a tile
 * that changed colour on a number would be this page deciding what a good
 * answer rate is, which is the customer's judgement and not ours (rule 2). */
/* An empty grid still draws its own border, and a 2px hairline box above the
 * "results appear here after the first calls" line is a card that looks broken
 * rather than early. */
.cmp-tiles:empty { display: none; }
.cmp-tiles { display: grid; gap: 1px; background: var(--c-line);
             border: 1px solid var(--c-line); border-radius: var(--r-xs);
             grid-template-columns: repeat(auto-fit, minmax(126px, 1fr)); }
/* The 1px grid `gap` over a line-coloured background IS the hairline between
 * tiles — one rule, drawn once, so tiles that wrap onto a second row cannot end
 * up with a doubled edge the way per-tile borders would. */
.cmp-tile { background: var(--c-surface); padding: 10px 12px; min-width: 0;
            display: flex; flex-direction: column; gap: 2px; }
.cmp-tile b { font-size: 17px; font-weight: 600; letter-spacing: -0.02em;
              color: var(--c-ink); line-height: 1.15; min-width: 0;
              overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cmp-tile span { font-size: 10.5px; letter-spacing: 0.04em; color: var(--c-ink-3);
                 font-weight: 500; overflow-wrap: anywhere; }
/* The card while the report is being built. It loops, which rule 3 forbids for
 * decoration and allows for a thing that is genuinely happening — the licence
 * #speaking takes. It exists only while a request is in the air. */
.cmp-skel { display: block; border-radius: var(--r-xs); background: var(--c-sunk);
            animation: pulse 1.4s ease-in-out infinite; }
.cmp-skel-n { height: 17px; width: 58%; margin-bottom: 3px; }
.cmp-skel-w { height: 8px; width: 82%; }

/* People to act on. Three rows, and they are the reason the full report gets
 * opened: everything else on this page is counters, and this is the part that
 * is work rather than reading. */
.cmp-people:empty { display: none; }
.cmp-people { margin-top: 14px; }
.cmp-people-head { font-size: 10px; letter-spacing: 0.08em;
                   text-transform: uppercase; color: var(--c-ink-3);
                   font-weight: 600; margin-bottom: 2px; }
.cmp-person { display: grid; grid-template-columns: minmax(90px, 0.9fr)
              minmax(0, 2fr) auto; align-items: baseline; gap: 10px;
              padding: 8px 0; font-size: 12px;
              border-bottom: 1px solid var(--c-line); }
.cmp-person b { color: var(--c-ink); font-weight: 600; min-width: 0;
                overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* One line. A model's summary of a phone call is as long as it likes, and a
 * strip whose rows are three lines tall is a strip nobody reads to the end. */
.cmp-person-say { color: var(--c-ink-2); min-width: 0; overflow: hidden;
                  text-overflow: ellipsis; white-space: nowrap; }
.cmp-person:last-child { border-bottom: none; }
.cmp-person-what { color: var(--c-ink-3); font-size: 11px; text-align: right;
                   white-space: nowrap; }
.cmp-people-more { font-size: 11.5px; color: var(--c-ink-3); padding-top: 8px; }
/* The card's own controls, and the only place they are: Insights, Share and the
 * CSV are what a customer does with this answer, so they sit under it. */
.cmp-res-acts { margin-top: 16px; }
.cmp-res-quiet { margin: 0; }

/* ── the progress bar ───────────────────────────────── */
.cmp-bar-track { display: flex; width: 100%; height: 10px; overflow: hidden;
                 border-radius: var(--r-xs); background: var(--c-line);
                 margin: 4px 0 12px; }
.cmp-seg { display: block; height: 100%; min-width: 2px; }
/* The same track, thin, under a list row's two numbers. It has to be declared
 * AFTER .cmp-bar-track and not beside the list rules that use it: both are one
 * class deep, so whichever is written last wins, and a 10px bar with 12px of
 * margin inside a table row is a table row twice as tall as its neighbours. */
.cmp-minitrack { height: 3px; margin: 0; border-radius: 1px; }
/* The 2px floor above exists so a segment worth 0.3% of a campaign is still
 * visible in the detail bar. Here there is one segment and it is allowed to be
 * nothing: a 2px nub of accent on a campaign that has finished no calls says
 * something has, which is the one thing this bar must never say. */
.cmp-minitrack .cmp-seg { min-width: 0; }
.cmp-swatch { display: inline-block; width: 9px; height: 9px; flex: none;
              border-radius: 1px; border: 1px solid var(--c-line-strong); }
/* five tones, all of them already in the palette */
.t-done { background: var(--accent); border-color: var(--accent); }
.t-soft { background: var(--c-ink-4); border-color: var(--c-ink-4); }
.t-bad  { background: var(--c-alarm); border-color: var(--c-alarm); }
.t-mute { background: var(--c-line-strong); border-color: var(--c-line-strong); }
.t-live { background: var(--c-ink-2); border-color: var(--c-ink-2); }
.t-wait { background: var(--c-sunk); border-color: var(--c-line-strong); }
.cmp-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; }
.cmp-leg { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px;
           color: var(--c-ink-3); }
.cmp-leg b { color: var(--c-ink); font-weight: 600; }

/* ── the contacts table ─────────────────────────────── */
/* The phone column has a floor as well as a share, and the time column has only
 * a share. That ordering is the whole responsive rule for this table: when the
 * card narrows, the thing that gives is the date — which degrades to "22 Aug"
 * and still reads — and never the number, which degrades to a truncated phone
 * number, which is a wrong phone number. */
.cmp-crowhead, .cmp-crow {
  grid-template-columns: minmax(126px, 1.2fr) 118px 84px minmax(0, 1fr);
}
.cmp-crow { cursor: default; }
.cmp-crow:hover { background: transparent; box-shadow: none; }
.cmp-c-phone { color: var(--c-ink); font-weight: 500; min-width: 0;
               overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cmp-c-att { color: var(--c-ink-2); }
/* The outcome, with its tone as a mark beside it. The word is still what says
 * what happened (rule 2) — the swatch only lets a column of twenty rows sort
 * itself out at a glance, and it is the same swatch, the same size and the same
 * five tones as the progress legend two cards up. */
.cmp-c-cstatus { color: var(--c-ink-2); min-width: 0; display: flex;
                 align-items: center; gap: 7px; }
.cmp-c-cword { min-width: 0; overflow: hidden; text-overflow: ellipsis;
               white-space: nowrap; }
/* The last cell is a control and a time: press the circle to hear the call,
 * read the line beside it to know when it happened and how long it lasted.
 * .hist-play is reused rather than re-styled — it is the same act as the Calls
 * page, and a second circle that looked slightly different would read as a
 * different thing. Smaller here only because a table row is not a list row.
 *
 * It is no longer mono. It printed a 26-character call id, which is the one
 * kind of string that needs a monospace face; a date does not, and setting one
 * in mono is how a human-readable fact ends up looking like a machine's. */
.cmp-c-call { font-size: 11.5px; color: var(--c-ink-3);
              min-width: 0; display: flex; align-items: center; gap: 8px; }
.cmp-lastcall { min-width: 0; display: flex; align-items: baseline; gap: 5px;
                overflow: hidden; }
.cmp-when { color: var(--c-ink-2); white-space: nowrap; overflow: hidden;
            text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
.cmp-dur:empty { display: none; }
.cmp-dur { color: var(--c-ink-3); white-space: nowrap; flex: none;
           font-variant-numeric: tabular-nums; }
.cmp-play { width: 26px; height: 26px; flex: none; }
.cmp-play svg { width: 11px; height: 11px; }
/* The player mounts as the row's next sibling, inside the contacts grid — it
 * is not a row and must not be laid out as one. */
.cmp-crow + .player-wrap { display: block; padding: 4px 0 10px; }

/* ── do-not-call ────────────────────────────────────── */
.cmp-dncrow { display: grid; grid-template-columns: minmax(0, 1fr) auto auto;
              align-items: center; gap: 10px; padding: 9px 0; font-size: 12px;
              border-top: 1px solid var(--c-line); }
.cmp-dncrow:first-child { border-top: none; }
.cmp-dncwhen { color: var(--c-ink-3); font-size: 11px; }

/* ── the create dialog ──────────────────────────────────────────────────────
 * A real <dialog> with showModal(), so the focus trap, Escape, the scrim and
 * the inert page all come from the platform. It borrows the gate card's
 * entrance (MAGI's `.dialog`: 200ms, scale from 0.97) rather than inventing a
 * second one, and the ::backdrop is the same scrim the phone sheet uses — the
 * only two dimming surfaces in the build. */
dialog.cmp-dlg { position: fixed; inset: 0; width: 100%; max-width: none;
                 height: 100%; max-height: none; margin: 0; padding: 0;
                 border: 0; background: transparent; overflow: hidden;
                 z-index: 60; }
dialog.cmp-dlg[open] { display: flex; align-items: center;
                       justify-content: center; }
dialog.cmp-dlg::backdrop { background: var(--c-scrim); }
.cmp-dlgpanel { width: min(720px, calc(100% - 32px)); max-height: 92vh;
                display: flex; flex-direction: column; overflow: hidden;
                background: var(--c-surface); border: 1px solid var(--c-line);
                border-radius: var(--r-md);
                box-shadow: 0 8px 28px rgba(27, 26, 22, 0.07);
                animation: dialog-in 200ms var(--ease-out) both; }
.cmp-dlgpanel:focus, .cmp-dlgpanel:focus-visible { outline: none; }
.cmp-dlghead { display: flex; align-items: center; justify-content: space-between;
               gap: 10px; padding: 12px 18px;
               border-bottom: 1px solid var(--c-line); }
.cmp-dlghead h2 { margin: 0; font-size: 15px; font-weight: 600;
                  letter-spacing: -0.015em; color: var(--c-ink); }
.cmp-dlghead .sheet-x { display: flex; width: 34px; height: 34px; padding: 0;
                        align-items: center; justify-content: center;
                        background: none; border: 0; color: var(--c-ink-3); }
.cmp-dlghead .sheet-x svg { width: 17px; height: 17px; }
.cmp-dlgbody { padding: 16px 18px 18px; overflow-y: auto;
               overscroll-behavior: contain; }
.cmp-dlgacts { display: flex; justify-content: flex-end; gap: 8px;
               margin-top: 18px; padding-top: 14px;
               border-top: 1px solid var(--c-line); }

.cmp-field { margin-bottom: 14px; min-width: 0; }
.cmp-field > label { display: block; margin-bottom: 5px; }
.cmp-field input[type=text], .cmp-field input[type=tel],
.cmp-field input[type=date], .cmp-field input[type=time],
.cmp-field input[type=number], .cmp-field select { width: 100%; }
.cmp-field .dev-help { margin-top: 5px; }
.cmp-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
           gap: 0 12px; }
.cmp-three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
             gap: 0 12px; }

.cmp-slot { display: grid; grid-template-columns: 1fr auto 1fr auto;
            align-items: center; gap: 8px; margin-bottom: 6px; }
.cmp-slot-dash { color: var(--c-ink-3); font-size: 12px; }
.cmp-slot-x { background: none; border: 0; color: var(--c-ink-3); padding: 4px 6px;
              font-size: 12px; line-height: 1; }
.cmp-slot-x:hover { color: var(--c-alarm-ink); background: none; transform: none; }

.cmp-filerow { display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
               flex-wrap: wrap; }
.cmp-filerow input[type=file] { flex: 1; min-width: 0; font-size: 11.5px;
                                color: var(--c-ink-2); }
.cmp-contacts textarea { min-height: 120px; font-family: var(--mono);
                         font-size: 12px; }

/* What the browser made of the file, before a campaign exists. The point of
 * showing it is the LAST block — the rows that cannot be dialled and why. */
.cmp-preview { margin-top: 10px; border: 1px solid var(--c-line);
               border-radius: var(--r-sm); background: var(--c-sunk);
               padding: 10px 12px; }
.cmp-prevsum { font-size: 12px; font-weight: 600; color: var(--c-ink);
               margin-bottom: 8px; }
.cmp-prevrow { display: grid; grid-template-columns: 150px minmax(0, 1fr);
               gap: 10px; font-size: 11.5px; color: var(--c-ink-2);
               padding: 2px 0; font-variant-numeric: tabular-nums; }
.cmp-prevvars { color: var(--c-ink-3); min-width: 0; overflow: hidden;
                text-overflow: ellipsis; white-space: nowrap; }
.cmp-prevmore { font-size: 11.5px; color: var(--c-ink-3); padding-top: 4px; }
.cmp-bad { margin-top: 10px; padding: 9px 10px; border-radius: var(--r-xs);
           background: var(--c-open-bg); border: 1px solid var(--c-open-line); }
.cmp-badhead { font-size: 11.5px; font-weight: 600; color: var(--c-open-ink);
               margin-bottom: 4px; }
.cmp-badrow { font-size: 11.5px; color: var(--c-ink-2); line-height: 1.5;
              overflow-wrap: anywhere; }

.cmp-upload { margin-top: 12px; }
.cmp-upload .cmp-bar-track { margin: 0 0 6px; }
.cmp-upload .cmp-bar-track i { display: block; height: 100%; width: 0%;
                               background: var(--c-ink-2);
                               transition: width 180ms var(--ease-out); }

/* ── the pane swap, and the card arriving ───────────────────────────────────
 * Four panes in one view and no URL between them, so nothing but motion says a
 * navigation happened. 180ms and 4px is the smallest amount that reads as one —
 * enough to see, not enough to wait for. It fires on the swap only: a `hidden`
 * section that becomes visible restarts its animation, and a repaint of a pane
 * already on screen does not touch it, so the poll cannot make the page blink
 * every four seconds.
 *
 * The Results card gets its own, because it arrives later than the pane it is
 * in — the report is a separate request — and a card that appears with no
 * motion at all reads as a page that jumped. */
#camplistpane, #campdetail, #campreport, #campdnc {
  animation: cmp-pane-in 180ms var(--ease-out) both;
}
.cmp-res { animation: cmp-pane-in 220ms var(--ease-out) both; }
@keyframes cmp-pane-in { from { opacity: 0; transform: translateY(4px); }
                         to   { opacity: 1; transform: none; } }

@media (max-width: 900px) {
  .cmp-rowhead, .cmp-row { grid-template-columns: minmax(0, 1fr) 96px 92px; }
  .cmp-rowhead span:last-child, .cmp-row .cmp-c-when { display: none; }
  /* The narrow layout keeps the whole of the last column, because the last
   * column is now the point of this table: the circle that plays the call and
   * the time it happened. What gives instead is the duration — a second fact
   * about a call you can simply play — and the Attempts column, which loses its
   * heading room rather than its number. */
  .cmp-crowhead, .cmp-crow {
    grid-template-columns: minmax(104px, 1fr) 92px 52px minmax(0, 1.05fr);
  }
  .cmp-crow .cmp-dur { display: none; }
}
@media (max-width: 760px) {
  .cmp-two, .cmp-three { grid-template-columns: minmax(0, 1fr); }
  .cmp-facts { grid-template-columns: minmax(0, 1fr); }
  .cmp-dlgpanel { width: 100%; max-width: none; height: 100%; max-height: none;
                  border: 0; border-radius: 0; }
  .cmp-acts button { flex: 1 1 auto; }
  /* Two tiles a row rather than the auto-fit's one: six numbers stacked into a
   * column is a scroll, and these six are meant to be one glance. */
  .cmp-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .cmp-tile b { font-size: 15px; }
  /* The summary is the first thing to go when there is no room for three
   * columns — the name and what they want are the row; the sentence is in the
   * report. */
  .cmp-person { grid-template-columns: minmax(0, 1fr) auto; }
  .cmp-person-say { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .cmp-dlgpanel { animation: none; }
  .cmp-upload .cmp-bar-track i { transition: none; }
  /* The swap keeps its opacity — that is what says a navigation happened — and
   * loses the movement. The skeleton stops pulsing and is simply held, which
   * still reads as "not this, yet". */
  #camplistpane, #campdetail, #campreport, #campdnc,
  .cmp-res { animation: fade-in 160ms var(--ease-out) both; }
  .cmp-skel { animation: none; opacity: 0.6; }
  .cmp-row { transition: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
 * THE FEEDBACK PASS — five additions that all serve one rule: an answer to a
 * press lands near the press, in this console's own language.
 *
 *   1. #confirmdlg  the house confirm, replacing the last native confirm()s
 *   2. .agent-status feedback chip inside the agent card header
 *   3. #mutebtn     the live call's second control (browser calls only)
 *   4. .playline    hear the opening line without dialing
 *   5. lat ↔ chat   latency rows open their turn in the transcript
 * ═══════════════════════════════════════════════════════════════════════════ */

/* ── 1 · the house confirm ──────────────────────────────────────────────────
 * The campaigns dialog's wrapper pattern, at question size: full-screen
 * transparent <dialog>, centred panel, ::backdrop = --c-scrim (one of the two
 * dimming surfaces in the build), MAGI's dialog-in entrance. The destructive
 * confirm borrows button.hang's recipe — tinted alarm-bg, never a solid red
 * slab ("a solid red button in a page of quiet ones is a dare") — and focus
 * starts on Cancel from core.js, so Enter-through is not the default path on
 * a delete. */
dialog.confirmdlg { position: fixed; inset: 0; width: 100%; max-width: none;
                    height: 100%; max-height: none; margin: 0; padding: 0;
                    border: 0; background: transparent; overflow: hidden;
                    z-index: 70; }
dialog.confirmdlg[open] { display: flex; align-items: center;
                          justify-content: center; }
dialog.confirmdlg::backdrop { background: var(--c-scrim); }
.cf-panel { width: min(420px, calc(100% - 32px)); background: var(--c-surface);
            border: 1px solid var(--c-line); border-radius: var(--r-md);
            padding: 16px 18px;
            box-shadow: 0 8px 28px rgba(27, 26, 22, 0.07);
            animation: dialog-in 200ms var(--ease-out) both; }
.cf-panel:focus, .cf-panel:focus-visible { outline: none; }
#cf-title { margin: 0 0 7px; font-size: 14px; font-weight: 600;
            letter-spacing: -0.01em; color: var(--c-ink);
            overflow-wrap: anywhere; }
#cf-body { margin: 0 0 15px; font-size: 12.5px; line-height: 1.6;
           color: var(--c-ink-2); overflow-wrap: anywhere; }
#cf-body[hidden] { display: none; }
.cf-acts { display: flex; justify-content: flex-end; gap: 8px; }
.confirmdlg.danger #cf-ok { background: var(--c-alarm-bg);
                            color: var(--c-alarm-ink); border-color: var(--c-alarm);
                            font-weight: 600; }
.confirmdlg.danger #cf-ok:hover { background: var(--c-alarm-bg-hover); }

/* ── 2 · feedback where the hand was ────────────────────────
 * Rename/create/delete/template/draft all report HERE now, beside the buttons
 * that triggered them, instead of in the rail's status line a metre away.
 * Fades out on itself (agentSay()); reduced motion just flips it. */
.agent-status { font-size: 11px; font-weight: 500; color: var(--c-ink-2);
                opacity: 0; white-space: nowrap; overflow: hidden;
                text-overflow: ellipsis;
                transition: opacity 140ms var(--ease-out); }
.agent-status.on { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .agent-status { transition: none; }
  .cf-panel { animation: none; }
}

/* ── 3 · mute ───────────────────────────────────────────────
 * Revealed by startCall(), hidden by stopCall(); sits between End-call and
 * the mic meter, because it answers "what exactly am I sending?". Resting =
 * ghost (a control offered); muted = solid ink (a state asserted) — weight
 * and ink carry the state per rule 2, and the label says it twice over. */
.mutebtn { display: flex; align-items: center; justify-content: center;
           gap: 7px; width: 100%; padding: 8px 10px; font-size: 12px;
           font-weight: 500; line-height: 1;
           background: var(--c-surface); color: var(--c-ink-2);
           border: 1px solid var(--c-line-strong); border-radius: var(--r-sm);
           cursor: pointer;
           transition: border-color 90ms ease, background-color 90ms ease; }
.mutebtn svg { width: 14px; height: 14px; flex: 0 0 auto; }
.mutebtn:hover { color: var(--c-ink); border-color: var(--c-ink-4); }
.mutebtn.muted { background: var(--c-ink); border-color: var(--c-ink);
                 color: var(--c-paper); }
/* call.js toggles the hidden attribute; display:flex would beat the UA rule. */
.mutebtn[hidden] { display: none; }

/* ── 4 · hear the opening line ──────────────────────────────
 * A 24px play control in the editor head, same line as the character count.
 * Playing = sunk fill + the stop glyph; both are drawn in the markup, the
 * switch is CSS, exactly like the showcase rows' play/pause pair. */
.playline { flex: 0 0 auto; align-self: center;
            display: inline-flex; align-items: center;
            justify-content: center; width: 24px; height: 24px; padding: 0;
            border: 1px solid var(--c-line-strong); border-radius: var(--r-sm);
            background: var(--c-surface); color: var(--c-ink-3);
            cursor: pointer; }
.playline svg { width: 11px; height: 11px; }
.playline .ico-stop { display: none; }
.playline:hover { color: var(--c-ink); border-color: var(--c-ink-4); }
.playline.playing { color: var(--c-ink); border-color: var(--c-ink);
                    background: var(--c-sunk); }
.playline.playing .ico-go { display: none; }
.playline.playing .ico-stop { display: block; }

/* ── 5 · the chart opens the transcript ─────────────────────
 * Latency rows gained data-turn + tabindex; cursor and focus say they are
 * controls. The flash is one-shot on a real event (the click), never ambient:
 * the bubble lifts once under a hairline ring and settles by itself. */
.lat-row.lat-link { cursor: pointer; }
.msg.hit { animation: msg-hit 1500ms var(--ease-out) both; }
@keyframes msg-hit {
  0%   { box-shadow: inset 0 0 0 2px var(--c-ink); background: var(--c-sunk); }
  70%  { box-shadow: inset 0 0 0 2px var(--c-ink); }
  100% { box-shadow: inset 0 0 0 0 rgba(27, 26, 22, 0); }
}
@media (prefers-reduced-motion: reduce) {
  .msg.hit { animation: none; box-shadow: inset 0 0 0 2px var(--c-ink); }
}

/* ── listen back, in the rail ───────────────────────────────
 * #callplayer holds the history player after a call ends. Hidden until there
 * is something in it; the player-wrap padding already gives it breathing room,
 * so the container adds none of its own. */
#callplayer[hidden] { display: none; }
