/**
 * Confidence Talk - Orb (Phase B: EEH-architecture rebuild)
 *
 * Styles for the 4-state voice orb. Replaces the old 11-state stylesheet
 * (`ct-agent-voice-presence.css`, 558 lines) which had rules for
 * `reviving`, `deaf`, `rest`, `doze`, `sleep`, `holding`, `cancel` and
 * `attending` — states that no longer exist, because the loop that produced
 * them has been deleted.
 *
 * The orb reuses the SAME markup (#ct-avp-orb / .ct-avp-ring / .ct-avp-icon /
 * .ct-avp-label) so the existing PHP that prints it does not change. Only the
 * classes it drives change, from `ct-avp-*` (11) to `ct-orb-*` (4).
 *
 * Mobile-first: the orb is 56px (>= the 44px touch minimum in qa/config.json),
 * respects safe-area insets, and honours prefers-reduced-motion.
 *
 * @package ConfidenceTalk
 */

/* ── ★ THE ORB GEOMETRY CONTRACT (2026-09-22 docking rebuild) ────────────
 *
 * Every surface in this file is positioned from the orb, and the orb is
 * positioned from TWO constants that are defined here and referenced everywhere
 * else. They are CSS custom properties so the arithmetic can never drift:
 *
 *     --ct-orb-size   the orb's diameter (56px desktop, 52px on small phones)
 *     --ct-orb-inset  its inset from the viewport edge (18px, 14px on small)
 *
 *     orb's outer (right) edge = --ct-orb-inset
 *     orb centre (from right)  = --ct-orb-inset + (--ct-orb-size / 2)
 *                              = 18 + 28 = 46px   (desktop)
 *
 * ★ THE DOCK — WHY THE PANEL NOW SHARES THE ORB'S EDGES.
 *
 * The visitor's report (2026-09-22):
 *
 *     "the orb is at the bottom right and the caption window in dark is little
 *      above on the left … perhaps if it was inside the caption window to reduce
 *      the space covered by the whole chat interface … i see more empty gap on
 *      the right of the caption window, what if the orb … is on the extreme
 *      right, touching … the right circular borders"
 *
 * He was measuring something real. The previous design pinned the panel's right
 * edge to the orb's AXIS (`--ct-orb-centre` = 46px) and left a deliberate 14px
 * vertical clearance above the orb — so the bar's right edge stopped 28px short
 * of the orb's right edge and 14px short of its top. That 28px sliver + the 14px
 * gap was real screen the interface did not use, and it read as two objects
 * rather than one.
 *
 * ── ★ DOCKED, NOT NESTED, AND THE DIFFERENCE MATTERS ──────────────────────
 * The orb was NOT moved into the panel's DOM. It is still a `position: fixed`
 * SIBLING appended to `<body>` by `_build()`, and the panel is still `z-index:
 * 9997` while the orb is `9998`. Only the panel's box moved. Three concrete
 * reasons nesting was rejected:
 *
 *   1. ★ THE MUTE'S REVEAL RULE IS A SIBLING SELECTOR. `_paint()` writes
 *      `data-ct-orb-state` on `#ct-avp-orb`, and the ONLY rule that makes the
 *      mute reachable while she speaks is
 *          #ct-avp-orb[data-ct-orb-state="speaking"] ~ .ct-orb-panel .ct-orb-mute
 *      Nesting the orb inside the panel would silently kill that `~`, and it is
 *      the one path that works on a TOUCH device (no hover). `V4`/`OB-29`/`OB-30`
 *      lock it, and it is an accessibility need, not decoration.
 *   2. The mobile stage (`_stage()`) drives BOTH elements with one class and
 *      expects the orb to be free to grow to a centred 112px hero. Re-parenting
 *      would make the staged orb a child of a full-screen sheet it must escape.
 *   3. The orb's own state machine, `_paint()`, `_collapse()` and the boot block
 *      all query `$("#ct-avp-orb")` at body level.
 *
 * ── SO WHAT MAKES IT *LOOK* NESTED? ───────────────────────────────────────
 *   • the panel's `right` and `bottom` are now `--ct-orb-inset` — the SAME two
 *     edges the orb uses, so they are flush;
 *   • the panel's bottom-right radius is fully round (999px) against the orb's
 *     own 50% circle, so the orb sits exactly ON the bar's right cap;
 *   • the panel carries `padding-bottom: calc(var(--ct-orb-size) + 11px)` so the
 *     input row can never slide under the portrait.
 * The orb over the panel's rounded corner IS the "circular right end" that was
 * asked for, achieved with no DOM change at all.
 *
 * ⚠ The `--ct-orb-*` values are declared on `:root` so BOTH `#ct-avp-orb` and
 * `.ct-orb-panel` can resolve them (they are siblings, not nested).
 * ⚠ EVERYTHING IN THIS BLOCK IS DESKTOP-ONLY (min-width: 481px). On a phone the
 * panel is a full-screen stage with `inset: 0` and `border-radius: 0`; the dock
 * must never leak into it. See the ≤480px block.
 */
:root {
  --ct-orb-size: 56px;
  --ct-orb-inset: 18px;
  /* The orb's horizontal centre, measured from the viewport's right edge. */
  --ct-orb-centre: calc(var(--ct-orb-inset) + (var(--ct-orb-size) / 2));
  /* ★ THE BREATHING ROOM BESIDE HER (v7). The gap between the orb and the
     "Text or talk" pill. It is a CONSTANT rather than a per-element margin so
     that anything later placed in the orb's row spaces itself the same way —
     the same reasoning as `--ct-orb-inset`. */
  --ct-orb-gap: 10px;
  /* The one gold. Every accent in this file reads from here. */
  /* ★★ THE ORB THEME CONTRACT (v8) — THE DEFAULTS, AND THEY MATTER.
   *
   * These are the SHIPPED VALUES. PHP inlines the admin's chosen theme on `:root`
   * (see `CT_Agent_Voice_Presence::orb_theme_css()`), and because that block is
   * emitted in the same `:root` scope but LATER in the cascade, the admin's values
   * win. ⚠ These fallbacks exist for the case where the theme block is absent — a
   * cached page, a theme freshly added, a PHP error — and they are also what
   * `qa/orb_theme_test.py` treats as "the default theme's expected values".
   *
   * ⚠ DECLARE EVERY NEW PROPERTY HERE OR THE ORB BREAKS SILENTLY. A missing
   * `--ct-panel-metal-a` would make `linear-gradient()` invalid, which drops the
   * WHOLE background-image declaration — the panel would render flat with no
   * gradient and no error. That is why the gradient is written with `var(…, <literal>)`
   * fallbacks at the point of use as well as here: two independent layers, because
   * this failure has no visible error message. */
  --ct-orb-gold: #cd853f;
  --ct-orb-gold-lit: #e09a52;
  --ct-orb-ink: rgba(18, 18, 20, 0.82);
  --ct-panel-metal-a: rgba(58, 58, 66, 0.92);
  --ct-panel-metal-b: rgba(30, 30, 34, 0.90);
  --ct-panel-metal-c: rgba(10, 10, 12, 0.94);
  --ct-panel-edge: rgba(255, 255, 255, 0.12);
  --ct-panel-sheen: rgba(255, 255, 255, 0.05);
  --ct-orb-text: #ffffff;
  --ct-orb-dim: rgba(255, 255, 255, 0.62);
}

/* ── the orb ─────────────────────────────────────────────────────────── */

/* ══ ★★ DRAGGABLE ORB (2026-09-25) — THE DOCK HAS ONE POSITION VARIABLE PAIR ══
 *
 * The visitor: *"make the orb dragable to any place on the page, both for desktop and
 * the mobile, so user can hold and drag to any corner or location."*
 *
 * ── WHY THIS IS VARIABLES AND NOT PER-ELEMENT OFFSETS ────────────────────────
 * THREE surfaces are built on the orb's position, and each already derived it
 * independently from `--ct-orb-inset`:
 *     1. `#ct-avp-orb`      the orb itself
 *     2. `.ct-orb-panel`    the bar, anchored to the SAME two edges (V1 asserts
 *                           `bar_right_edge_is_flush_with_the_orb`, delta <= 2px)
 *     3. `.ct-orb-peek`     the "Text or talk" pill beside her
 * Patching a `transform` onto the orb alone would leave the bar and the pill behind —
 * which is exactly the "out of line" fault an earlier session fixed by making them
 * share `--ct-orb-inset` in the first place.
 *
 * So the dock gets ONE pair of position variables and everything reads it:
 *
 *     --ct-orb-x / --ct-orb-y = the drag offset (0 when never dragged)
 *
 * ⚠ THE DEFAULT IS `0` FOR BOTH, AND THAT IS LOAD-BEARING. Every existing anchor is
 * expressed as `right`/`bottom` from the viewport edge; adding `0` leaves every one of
 * them BYTE-IDENTICAL to before the feature existed. A drag then moves the dock by
 * NEGATIVE `right`/`bottom` (further from that edge). Nothing about the un-dragged
 * layout, the geometry contract, `V1`, `V14` or `V15` changes until a visitor actually
 * drags — which is why the whole existing suite stays green through this feature.
 *
 * ⚠ THEY LIVE ON `:root` because the three surfaces are SIBLINGS in `<body>`
 * (deliberately — the mute's reach rule depends on it), and a custom property only
 * inherits DOWN the tree. `:root` is the one ancestor they share.
 * ⚠ THE VALUES ARE PUBLISHED INLINE BY JS ON `document.documentElement`, so they beat
 * the declarations below. Those are the un-dragged defaults and the fallback for a
 * page where the loop never ran.
 */
:root {
  --ct-orb-x: 0px;
  --ct-orb-y: 0px;
}

#ct-avp-orb {
  position: fixed;
  right: calc(max(var(--ct-orb-inset), env(safe-area-inset-right, 0px)) - var(--ct-orb-x));
  bottom: calc(max(var(--ct-orb-inset), env(safe-area-inset-bottom, 0px)) - var(--ct-orb-y));
  width: var(--ct-orb-size);
  height: var(--ct-orb-size);
  border-radius: 50%;
  cursor: grab;                        /* ★ it can be picked up now */
  z-index: 9998;
  display: flex;
  align-items: center;
  justify-content: center;
  /* ★ `touch-action: none` WHILE DRAGGABLE, NOT `manipulation` (2026-09-25).
     `manipulation` allows PAN/SCROLL gestures to reach the browser, which on a touch
     device means a drag on her would scroll the page INSTEAD of moving her — the
     gesture would work with a mouse and fail with a finger. `none` claims the gesture
     for us on both. The cost is that a drag starting ON the orb cannot scroll the
     page, which is correct: she is a 56px target and the page is scrollable everywhere
     else.
     ⚠ `_dragMove()` ALSO calls `preventDefault()`, so the two agree — do not rely on
     either one alone.
     ⚠ IT IS RESET TO `manipulation` THE MOMENT A DRAG ENDS (see `.ct-orb-dragging`
     below), because `none` would otherwise suppress the 300ms-tap-delay fix during
     normal taps. */
  touch-action: none;
  -webkit-tap-highlight-color: transparent;
  transition: transform 0.18s ease, box-shadow 0.18s ease, width 0.28s ease,
              height 0.28s ease, bottom 0.28s ease, right 0.28s ease;
  box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35);
  /* ★ LESS BLACK AROUND HER (2026-09-22, v4).
   *
   * The visitor: "make the hostess image little clearer, as looks like shaded".
   *
   * This disc sits directly behind the portrait and reads as a vignette around a
   * circular photo. At `rgba(18,18,20, 0.72)` plus `blur(6px)` it was doing two
   * darkening things at once: tinting the area around her almost opaque black, and
   * smearing the page behind it so the edge of the circle had no contrast to play
   * against.
   *
   * ⚠ IT CANNOT SIMPLY BE REMOVED. It is the guarantee that her face is legible
   * over ANY page — a light hero image, a white blog post, a busy gallery. That is
   * the whole reason a fixed floating orb has a backing at all. So it is LIGHTENED
   * rather than deleted: enough show-through that she reads as sitting on the page,
   * still enough darkness that a white background cannot wash her out.
   *
   * The blur drops further than the tint because blur is what made it look like
   * haze rather than depth. */
  background: rgba(18, 18, 20, 0.55);
  backdrop-filter: blur(3px);
}

/* ══ ★★ THE DRAG STATES (2026-09-25) ═════════════════════════════════════════
 *
 * Two attributes, two different jobs — and keeping them apart is what makes the
 * gesture feel right:
 *
 *   data-ct-orb-dragging="1"  the finger/mouse is DOWN and has passed the threshold.
 *                             Set for the duration of the move only.
 *   data-ct-orb-dragged="1"   she has been MOVED at least once (sticky). Used by the
 *                             `cursor` and the "picked up" shadow so she keeps
 *                             advertising that she is movable.
 *
 * ⚠ THE TRANSITION MUST BE OFF WHILE DRAGGING, AND THIS IS THE WHOLE REASON THE
 * ATTRIBUTE EXISTS. `#ct-avp-orb` carries
 *     `transition: … bottom 0.28s ease, right 0.28s ease`
 * for the dock animation. Left on during a drag, every pointermove would start a
 * fresh 0.28s ease toward the NEW position — so she would visibly LAG the cursor and
 * arrive late, which reads as "the drag is broken / she is fighting me". Suppressing it
 * for the duration is what makes her track the finger exactly.
 * ⚠ THE PANEL GETS THE SAME TREATMENT: it shares the offset, so without this the BAR
 * would trail behind her by a quarter second on every move and `V1`'s flush-edge
 * measurement (delta <= 2px) would fail mid-drag.
 * ⚠ IT IS DELIBERATELY NOT `!important` — the reduced-motion block already sets
 * `transition: none !important` on the orb, and the attribute selector here is strong
 * enough to win against the base rule without escalating. */
#ct-avp-orb[data-ct-orb-dragging="1"],
#ct-avp-orb[data-ct-orb-dragging="1"] ~ .ct-orb-panel {
  transition: none;
}
#ct-avp-orb[data-ct-orb-dragging="1"] {
  cursor: grabbing;
  /* Lifted: the shadow deepens so she reads as picked off the page. Purely a cue —
     no geometry depends on it, so it cannot break the dock contract. */
  box-shadow: 0 14px 34px rgba(0, 0, 0, 0.5);
  /* ⚠ `scale` VIA TRANSFORM WOULD FIGHT THE DRAG, so the lift is shadow-only. The
     hover rule already uses `transform: scale(1.06)`; composing a second transform
     here would have to be reconciled with it on every frame. Not worth it for a cue
     that the shadow already carries. */
}
#ct-avp-orb[data-ct-orb-dragged="1"] {
  cursor: grab;
}

/* ⚠ REDUCED MOTION, AND IT MATTERS MORE HERE THAN USUAL. The block below disables
   transitions globally for the orb; the drag already sets `transition: none` while
   moving, so a reduced-motion visitor gets the same instantaneous tracking — no
   regression, no separate rule needed. Recorded so the next reader does not add a
   duplicate. */

#ct-avp-orb:hover,
#ct-avp-orb:focus-visible {
  transform: scale(1.06);
  outline: none;
}

/* The portrait (hostess avatar), set via --ct-avp-portrait by PHP. */
#ct-avp-orb .ct-avp-icon {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: var(--ct-avp-portrait, none) center / cover no-repeat;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 26px;
  line-height: 1;
}

/* ── the ring (the ONE animation surface) ────────────────────────────── */

#ct-avp-orb .ct-avp-ring {
  position: absolute;
  inset: -6px;
  border-radius: 50%;
  border: 2px solid rgba(205, 133, 63, 0.55);
  pointer-events: none;
  transition: border-color 0.2s ease, transform 0.2s ease, opacity 0.2s ease;
  opacity: 0.75;
}

/* ── ★ THE SLEEP AFFORDANCE: `−` IS A SHARED CONTROL (2026-09-22, v6) ─────────
 *
 * ── WHAT WAS WRONG: DESKTOP HAD NO WAY TO PUT HER BACK TO SLEEP ───────────
 * The `−` button already existed — inside `.ct-orb-mobile-bar`, whose only rules
 * lived INSIDE the `@media (max-width: 480px)` block. So on desktop there was no
 * minimise affordance at all, and once the bar was revealed it stayed revealed
 * for the rest of the session. The visitor asked for the opposite:
 *
 *     "can we have a process, that orb remains in circle, and with short message
 *      like talk to me, in other wards, it is in minimized form, and quite, and
 *      asking user to wake her up, and once awaken the caption windows shows up
 *      and so on"
 *
 * ★ AND THE MEANING WAS ALREADY DOCUMENTED. `.clinerules` locks the mobile
 * behaviour: "`_collapse()` is NOT `_finish()`. Minimising hides the panel and
 * nothing else: no teardown, mic stays open, session continues." So `−` = SLEEP
 * was already the intended contract — it simply never reached desktop. Promoting
 * the control is therefore not a new concept; it is finishing one.
 *
 * ── WHY IT IS INVISIBLE AT REST, LIKE THE MUTE ────────────────────────────
 * The resting stripe's balance is the thing the visitor has been iterating on for
 * several rounds, so the sleep control must not disturb it. It ships at
 * `opacity: 0` (STILL IN THE LAYOUT, so the bar's geometry never shifts) and
 * appears on hover, on `:focus-visible`, and once engaged — the exact same
 * contextual pattern as `.ct-orb-mute`, which is also the only pattern that works
 * on a TOUCH device, where there is no hover at all.
 *
 * ⚠ IT IS NOT `display: none`. A `display: none` control cannot be focused or
 * announced, so keyboard and screen-reader visitors could never sleep her.
 * `opacity: 0` keeps it reachable by Tab while keeping it invisible to the eye —
 * the same trade `.ct-orb-mute` makes and which `V4` locks for the mute.
 */
@media (min-width: 481px) {
  /* ⚠⚠ THE WRAPPER: `height: 0` AND NOTHING ELSE (v6b — SIMPLIFIED).
   *
   * The v6 version of this rule carried a long history: the button used to live
   * inside this wrapper, absolutely positioned, and every attempt to place it needed
   * the wrapper to be a box. Three separate rounds of geometry work went into making
   * that work (out-of-flow vs. containing block, specificity, z-order over the orb's
   * expanded ring).
   *
   * ★ ALL OF IT IS NOW MOOT: THE BUTTON MOVED INTO `.ct-orb-head`. The only thing
   * left to solve here was that this wrapper had `display: none` on desktop while
   * still being laid out by the phone rules, so its height is simply zeroed. There
   * is no positioning left to get wrong. The grip is hidden because a "pull this
   * sheet away" affordance means nothing on a bar.
   *
   * ⚠ DO NOT MOVE THE FOLD CONTROL BACK IN HERE. It is in the head now, and the
   * reason is not cosmetic: a control in the wrapper has to fight the input row, the
   * orb's docked position and the orb's z-index (9998 vs the panel's 9997), whereas
   * in the head it shares a row with nothing but text. That is a structural win, not
   * a tidy-up. */
  .ct-orb-panel .ct-orb-mobile-bar {
    display: block;
    height: 0;
    min-height: 0;
    padding: 0;
    overflow: visible;
  }
  .ct-orb-panel .ct-orb-grip { display: none; }

  /* ── ★ THE FOLD CONTROL — BESIDE THE SPEAKER IN THE INPUT ROW (v7) ───────
   *
   * ── WHY IT MOVED AGAIN, AND WHY THAT IS NOT INDECISION ───────────────────
   * v6 put a `−` in the panel's bottom-right corner. v6b moved a CHEVRON into the
   * head's top-right. v7 puts a MINUS back at the right of the INPUT ROW, beside
   * the speaker — because that is literally what the visitor asked for:
   *
   *     "to quite the hostess, or to close the hostess, a minus sign next to the
   *      speaker icon is good, that will minimize or remove the caption interface"
   *
   * ★ THE ROW IS THE RIGHT HOME FOR A STRUCTURAL REASON, NOT A PREFERENCE. Read it
   * left to right and it is a complete sentence about her: [field] type here ·
   * [speaker] silence the voice · [minus] fold her away. Each control sits next to
   * what it acts on, which is the property v6b's corner did NOT have — that corner
   * shared a row with the caption, i.e. with TEXT, and text is the one thing that
   * changes height underneath a control.
   *
   * ⚠ SO WHY DID v6b MOVE IT OUT OF HERE? Because in v6 the row was CROWDED — it
   * held the field, the speaker AND the `→` send arrow, so a fourth control had to
   * sit on someone else's baseline. v7 removes the arrow (it duplicated the
   * placeholder), and that single deletion is what makes this placement clean. The
   * two changes pay for each other; neither works alone.
   *
   * ⚠ THE BEHAVIOUR IS UNCHANGED. It still calls `_collapse()` — minimise, never
   * `_finish()`. v7 changed WHERE the control is, never what it does.
   *
   * ── VISIBILITY ──────────────────────────────────────────────────────────
   * ⚠ ALWAYS VISIBLE NOW, matching the speaker beside it (v7 — the visitor's
   * "speaker icon is not visible but only when scrolled upon, should be visible all
   * the time"). The v6b reveal rules (hover / focus / engaged) are DELETED rather
   * than kept, because a reveal rule on a control that is already at `opacity: 1`
   * is dead code that reads as intent.
   *
   * ⚠ STILL `opacity` AND NEVER `display: none` in spirit — it must remain focusable
   * and announced for a keyboard visitor. `display: inline-flex` only ever applies
   * while the bar is open, and the whole panel is `hidden` at rest.
   *
   * ⚠ THE PAINTED CIRCLE IS 26px; THE TOUCH TARGET IS NOT. The `::after` below
   * extends it to a 44px square. A 44px painted circle would dominate a 44px input
   * row and force the field down to nothing — the same trap as v6.
   */
  /* ★ `−` REMOVED (2026-10-22) — a desktop-only margin belonged to a button that no
     longer exists. The fold is the orb tap now; see `_ctOrbToggle()` in
     ct-orb-loop.js. Deleting the rule WITH the button is the point: dead CSS for a
     deleted control is how a future reader "discovers" a control that isn't there. */
}

/* ── ★ THE "TEXT OR TALK" PEEK — THE INVITATION BESIDE THE ORB (v7) ──────
 *
 * ── WHAT IT IS ───────────────────────────────────────────────────────────
 * One small pill to the LEFT of the docked orb, carrying the same two words as
 * the field's placeholder. It answers the only question a cold visitor has about a
 * floating circle: "what is that, and what do I do with it?"
 *
 * ── ★ THE THREE PLACEMENTS, AND WHY ONLY THE THIRD IS ALLOWED ────────────
 * This element has been built and deleted TWICE, and both failures were the SAME
 * failure — the hint sat in the CAPTION'S BAND:
 *   • v1 (2026-09-15): ABOVE the orb (`bottom: 100% + 10px` → `84..106px`). The
 *     caption band was `86..174px`. The same 20px. "Partially on top of orb,
 *     visibility is bad."
 *   • v6 (2026-09-22): BELOW the orb. The collision was genuinely fixed and the
 *     visitor still rejected it: "remove the talk to me text the bottom of the orb".
 * ★ v7 IS TO THE LEFT, IN THE ORB'S OWN ROW, and that is a different thing rather
 * than a third guess. The caption already RESERVES the orb's full column through
 * `--ct-orb-clear`, so a pill inside that reserved strip cannot reach her words —
 * the overlap is prevented by the caption's own existing rule, not by a new one.
 *
 * ── ★ POSITIONED FROM THE SAME TWO CONSTANTS AS THE ORB ──────────────────
 *   orb left edge (from viewport right) = --ct-orb-inset + --ct-orb-size
 *   this pill's right edge              = that + --ct-orb-gap
 * ⚠ NO NEW MAGIC NUMBER. `--ct-orb-size` and `--ct-orb-inset` are the two
 * properties the entire file is positioned from (see the geometry contract at the
 * top), so the pill is flush with her BY CONSTRUCTION and cannot drift out of line
 * when the orb's size changes across breakpoints.
 *
 * ── ≜ VERTICALLY CENTRED ON HER, NOT ON THE VIEWPORT ─────────────────────
 * `bottom` reproduces the orb's own centre: inset + size/2, minus half the pill's
 * own height. A pill aligned to the viewport's bottom margin would sit visibly low
 * against a 56px orb.
 *
 * ⚠ `pointer-events: none`. It is an invitation, not a control — the thing to tap
 * is the ORB, and a pill that swallowed taps would steal them from the one target
 * that works. It is `aria-hidden` in the markup for the same reason.
 *
 * ⚠ DESKTOP ONLY (min-width: 481px). On a phone the orb becomes a centred 112px
 * hero inside a full-screen stage with its own typography, so a floating pill
 * beside it would land in the middle of the layout with nothing to align to.
 */
.ct-orb-peek {
  position: fixed;
  /* ★ DRAGGABLE (2026-09-25): the pill rides the SAME `--ct-orb-x/y` offset as the orb
     and the bar, so it cannot be left behind when the dock is dragged. It is a
     body-level sibling of both, so `:root` is the only place those two can be read. */
  right: calc(var(--ct-orb-inset) + var(--ct-orb-size) + var(--ct-orb-gap) - var(--ct-orb-x));
  bottom: calc(var(--ct-orb-inset) + (var(--ct-orb-size) / 2) - 14px - var(--ct-orb-y));
  z-index: 9997;                    /* under the orb (9998), over the page */
  display: flex;
  align-items: center;
  height: 28px;
  padding: 0 12px;
  border-radius: 999px;
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1;
  letter-spacing: 0.01em;
  white-space: nowrap;
  color: rgba(255, 255, 255, 0.86);
  background: var(--ct-orb-ink);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
  pointer-events: none;             /* the ORB is the target, never this */
  user-select: none;
}

/* ⚠ A NARROW DESKTOP IS NOT A PHONE. Below ~560px the orb and a ~104px pill
   together exceed the viewport, so the pill is dropped rather than allowed to
   collide with the orb or run off-screen. The orb's own aria-label and the open
   bar's placeholder still carry the instruction there. */
@media (max-width: 560px) {
  .ct-orb-peek { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .ct-orb-peek { transition: none; }
}

/* ── the status pill: REMOVED (2026-09-15) — AND STILL REMOVED (v6b/v7) ───
 *
 * The hover pill (`#ct-avp-orb .ct-avp-label`) has been DELETED, not restyled.
 *
 * WHY IT WAS ORIGINALLY DELETED: it sat ABOVE the orb
 * (`bottom: calc(100% + 10px)` → 84..106px) while the caption panel sat at
 * 86..174px — the SAME 20px band. Two text boxes drew over each other in different
 * colours, which was the reported "partially on top of orb, visibility is bad".
 * A hover-only pill is also invisible on touch, and it duplicated the caption,
 * which is always visible and does the job properly.
 *
 * ── ★ IT WAS BRIEFLY REVIVED (v6) AND THE VISITOR REJECTED IT (v6b) ────────
 * v6 brought it back to carry "Talk to me" BELOW a sleeping orb, which genuinely
 * fixed the collision above. The visitor's verdict was immediate and final:
 *
 *     "remove the talk to me text the bottom of the orb"
 *
 * ★ SO IT IS HIDDEN AGAIN, AND THE LESSON IS NOT "PUT IT IN A BETTER PLACE". The
 * v6 placement was measurably correct — `V19` proved the label sat clear of the
 * caption band — and it still had to go. A second text surface floating beside her
 * competes with her portrait for attention and says something she can say herself.
 * ⚠ "The geometry was right" is NOT the same as "the element belongs". The 2026-09-15
 * removal and the 2026-09-22 removal agree, which is the strongest signal available
 * that this element should stay gone.
 *
 * ── WHAT CARRIES THE SLEEPING ORB NOW ─────────────────────────────────────
 * MOTION AND FORM ALONE: the ring breathes (`ct-orb-sleep`) and she is a bare
 * circle with no bar. `prefers-reduced-motion` suppresses the pulse, which is
 * acceptable BECAUSE NOTHING IS LOST BY IT — waking her is the same gesture it has
 * always been (tap the orb), and no text invitation was ever the only way in.
 *
 * ⚠ DO NOT RE-USE THIS ELEMENT FOR INVITATIONS, HINTS OR HOVER COPY. It is kept in
 * the markup for parity with the PHP that prints it; the PHP/JS contract is
 * unchanged. `data-ct-orb-asleep` still marks a sleeping orb — it is simply no
 * longer wired to this node.
 */
#ct-avp-orb .ct-avp-label {
  display: none !important;
}

/* ── she is asleep: the ring breathes ────────────────────────────────────── */
#ct-avp-orb[data-ct-orb-asleep="1"] .ct-avp-ring {
  animation: ct-orb-sleep 2.6s ease-in-out infinite;
}

/* ★ A BREATH, NOT A BEAT. 1.07 here against the speaking ripple's 1.18: the
 * sleeping orb must be visibly the QUIETEST state she has, or "asleep" reads as
 * "busy". `prefers-reduced-motion` removes this globally (see that block) — and
 * that is a deliberate, accepted trade now that the pulse is the ONLY signal: a
 * visitor who suppresses motion still sees a bare 56px circle with no bar, which
 * is the quiet state they asked for rather than a broken one. */
@keyframes ct-orb-sleep {
  0%, 100% { transform: scale(1);    opacity: 0.55; }
  50%      { transform: scale(1.07); opacity: 0.30; }
}

/* ── ⚠ THE `:has()` GUARD WAS REMOVED (2026-09-22, v6) — AND IT IS WORTH RECORDING WHY.
 *
 * The first version added a belt-and-braces rule so an open panel could never show
 * the invitation:
 *
 *     #ct-avp-orb[data-ct-orb-asleep="1"]:has(~ .ct-orb-panel:not([hidden])) …
 *
 * IT MEASURED AS A BUG. The suite caught the label at `opacity: 0` while she was
 * genuinely asleep, because the selector's compound specificity
 * (`#id[attr]:has(...) .class` = 1-2-1) beats the show rule
 * (`#id[attr] .class` = 1-1-1). So whenever the panel happened to be open the
 * invitation was suppressed even in states where it should show — the rule won the
 * cascade BY ACCIDENT rather than by intent, and no amount of reading the stylesheet
 * would have shown it.
 *
 * ★ MORE IMPORTANTLY, IT GUARDED A FACT THE JS ALREADY OWNS. `_reveal()` clears
 * `data-ct-orb-asleep` on every path that opens the panel, and it is the single
 * chokepoint for all of them. Duplicating that in CSS created a second mechanism
 * that could disagree with the first — and per `.clinerules`, "two independent keys
 * that must agree will eventually disagree". Here the disagreement was immediate and
 * invisible: the invitation simply never appeared, for some visitors.
 *
 * ★ THE HONEST SHAPE IS ONE OWNER PER FACT. The JS decides AWAKE vs ASLEEP (it knows
 * the reason, the session and the gesture); the CSS decides what a sleeping orb
 * LOOKS like. The rules above key ONLY off the attribute, and `_reveal()` is the only
 * thing that clears it.
 */

/* ── caption + text panel — ★ DOCKED TO THE ORB (2026-09-22) ────────────
 *
 * WHAT THIS REPLACED, AND WHY:
 *
 * ── 2026-09-20: "unbalanced, and out of line" ────────────────────────────
 *     "I see the orb, the black question box saying type your question with
 *      enter arrow, and on top speaker icon to mute."
 * That round fixed the mute (moved into the input row) and the three-stacked-
 * boxes problem (one surface, one radius). But it aligned the bar's right edge
 * to the orb's AXIS (`--ct-orb-centre`, 46px) and left a 14px clearance above
 * the orb — which is correct-looking in isolation and still leaves real screen
 * unused: a 28px sliver right of the bar, and a 14px band between bar and orb.
 *
 * ── 2026-09-22: "more empty gap on the right of the caption window" ──────
 * The visitor measured that sliver and proposed the fix himself: put the orb on
 * the bar's extreme right, "touching … the right circular borders", so the
 * rectangle and the circle read as ONE object. That is what this rule does —
 * both offsets now resolve to `--ct-orb-inset`, the same edge the orb uses, and
 * the bottom-right radius becomes fully round so the orb sits ON the cap.
 *
 * ── WHAT IS PRESERVED (do not regress these) ────────────────────────────
 *   • ONE surface with ONE background (`--ct-orb-ink`) — was five.
 *   • The mute lives INSIDE the input row, never above it.
 *   • The bar is >= 420px so 14px prose still gets ~55-62 characters per line,
 *     which is the readability optimum and the reason the bar feels short.
 *   • `--ct-orb-caption-max` still auto-heights the caption to 4 lines.
 *   • ★ THE ORB IS STILL A SIBLING — the mute's mid-utterance reveal rule is
 *     `#ct-avp-orb[data-ct-orb-state="speaking"] ~ .ct-orb-panel .ct-orb-mute`
 *     and it DEPENDS on that. Do not nest the orb here. See the file header.
 */
.ct-orb-panel {
  position: fixed;
  /* ★ THE DOCK (2026-09-22) — see the geometry contract at the top of this file.
     ★ DRAGGABLE (2026-09-25): the bar reads the SAME `--ct-orb-x/y` offset as the orb,
     so dragging her carries the bar with her and `V1`'s flush-edge assertion
     (`delta <= 2px`) holds at every position, not just the default one. */
  right: calc(max(var(--ct-orb-inset), env(safe-area-inset-right, 0px)) - var(--ct-orb-x));
  bottom: calc(max(var(--ct-orb-inset), env(safe-area-inset-bottom, 0px)) - var(--ct-orb-y));
  /* Never wider than the space to the viewport's LEFT edge. `100vw` includes the
     scrollbar in some engines, hence the slack. */
  width: min(420px, calc(100vw - 42px));
  /* ★ THE BAR IS NEVER SHORTER THAN THE ORB (2026-09-22, v2; re-derived v4).
     The orb fills the bar's full height (see the `.ct-orb-dock` rule below), so
     the bar must be at least as tall as the orb's footprint or the circle would
     be clipped by its own container. This is a FLOOR, not a fixed height: a
     longer reply still grows the bar.
     ⚠ CONTENT-BOX, SO THIS IS THE **CONTENT** FLOOR (measured, not assumed).
     This stylesheet sets no global `box-sizing: border-box`, so `min-height` here
     excludes the panel's own vertical padding — which is why this expression must
     stay in step with `padding` below. The v2 form of this line set a 78px CONTENT
     floor while the comment claimed 78px total, and the panel rendered **100px**:
     the measurement the visitor reported as "102 px resting bar is still too tall".
     ★ v4 (the THIN STRIP) drops it again, to `--ct-orb-size` exactly. The resting
     panel's padding is now 6px, so the bar lands at 56 + 12 + 2px border = **70px**,
     and she is a 56px circle filling the strip — which is what makes her read
     LARGER even though the strip is smaller. See the `[data-ct-orb-resting]` block.
     ⚠ The expanded bar still needs the 11px padding, so the floor is only
     meaningful while resting; the resting block below overrides both together. */
  min-height: var(--ct-orb-size);
  z-index: 9997;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 11px;
  /* ★★ A BULLET, NOT A CAPSULE (2026-09-25). ────────────────────────────────
   *
   * The visitor, on the first attempt: *"cutting too much on the left, my idea was
   * wrong ... make it as thin as possible, but look like a bullet, SMALLER CURVES at
   * the corner, make it more balanced, short and logical."*
   *
   * So `999px` was wrong in the other direction. A capsule clamps to
   * `min(w,h)/2` — on a ~100px bar that is a **~48px curve**, which on a bar this
   * short reads as two big round ends with a thin sliver of text between them, i.e.
   * a stadium rather than a control. A bullet is the middle shape: **soft corners,
   * straight middle**, so the bar reads as a rounded rectangle at every size.
   *
   * ⚠ FIXED, NOT PROPORTIONAL (visitor's explicit choice: "fixed radius 10px"). A
   * `%` or a `height/N` rule would round MORE as a long reply grows the bar, which is
   * the opposite of "smaller curves". One number, the same at 76px and at 160px.
   *
   * ⚠ `10px` IS NOW THE SINGLE SOURCE OF TRUTH via `--ct-orb-radius`, so the value
   * can be tuned without hunting the declaration. Keep it ANONYMOUSLY SMALL: the
   * shape claim that holds this design together is **identical, non-zero corners
   * BELOW half the height** — a rectangle has `0`, a capsule has `>= h/2`, and both
   * are rejected by `V14` and by `_soft_corner_radius()` in `qa/voice_regression.py`.
   *
   * ⚠⚠ THIS HAS BEEN RE-DERIVED TWICE TODAY AND BOTH LOCK PAIRS MOVED WITH IT:
   *   `16px 999px 999px 16px` (rectangle-left) → `999px` (capsule) → `10px` (bullet).
   * Both re-derivations were REVERT-TESTED RED, and the new assertion rejects the two
   * rejected shapes as well as accepting this one. Do not "restore" a lock without
   * reading this block.
   * ⚠ AND ON THE PHONE THE STAGE IS STILL SQUARE: the ≤480px block resets this to
   * `0`, and `V1` locks that (`the_desktop_dock_does_not_leak_onto_the_stage`). */
  --ct-orb-radius: 10px;
  border-radius: var(--ct-orb-radius);
  /* ══ ★ THE TIERS CHANGE IN FRONT OF THE VISITOR — SO THEY MUST GLIDE ─═══════
   *
   * (2026-09-22, v5.) The whisper tier changes the panel's background, border,
   * shadow, padding and gap in one DOM attribute write. Without a transition all
   * five JUMP in the same frame, and the visitor sees a snap precisely at the
   * moment their answer would begin to read as spoken rather than computed.
   *
   * ★ AND `min-height` IS THE ONE THAT MATTERS. The bar grows 70px → ~88px when she
   * answers. That is the largest single movement in the whole orb, and an instant
   * 18px hop under her words is exactly the kind of jolt that reads as a glitch
   * rather than as an animation.
   *
   * ⚠ SHORTER THAN THE CAPTION'S OWN REVEAL, DELIBERATELY. The reveal already animates
   * her words in; if the frame arrived on the same clock the two would fight and the
   * bar would feel sluggish. 0.22s lets the frame settle just ahead of the text, so
   * the space exists before the words land in it.
   *
   * ⚠ RESPECTS `prefers-reduced-motion`, which is already handled below — that block
   * sets `transition: none !important` on `.ct-orb-panel`, so adding transitions here
   * does not opt out of it. */
  transition: background 0.22s ease, border-color 0.22s ease,
              box-shadow 0.22s ease, padding 0.22s ease, gap 0.22s ease,
              min-height 0.22s ease;
  /* ══ ★★ `color-scheme: dark` — THE EMPTY WHITE BOX WAS THE INPUT FIELD (2026-09-25) ══
   *
   * ── THE REPORT ────────────────────────────────────────────────────────────────
   *   "the empty white box is not too big ... with saying something, like text or talk
   *    etc. is no longer there"
   *
   * ── THE MEASUREMENT (real Chromium 1440x900, local, after her greeting) ────────
   *     .ct-orb-input   rect 47.8 x 198
   *     backgroundColor rgb(255, 255, 255)  ← the PURE-WHITE UA field, not our wash
   *     color           rgb(39, 38, 38)
   *     placeholder     "Text or talk…"     ← present in the DOM, painted WHITE
   *     --ct-orb-text   #f0fbf6
   *
   * ★ THE BOX THE VISITOR SAW WAS NEVER THE CAPTION. Measured in the same frame: the
   * caption was **0px tall with `display: none`** and the panel was 108.4px — i.e. the
   * tall empty rectangle WAS the input, and the missing "text or talk" was its
   * placeholder rendered white-on-white.
   *
   * ── WHY THE THEMED WASH WAS IGNORED ───────────────────────────────────────────
   * `background: rgba(255,255,255,0.06)` on `.ct-orb-input` is correct, but the page
   * being dark does not tell the ENGINE anything. With no author `color-scheme`, a
   * browser sitting in LIGHT mode gives a text input its light-mode UA treatment
   * (an opaque white field, plus UA-controlled text/placeholder/caret/selection
   * colours). ⚠ The admin panel already guards its own `<input type="date">` widgets
   * with an inline `color-scheme: dark` (`admin/class-admin.php`), which is the same
   * trap solved once — this rule applies the identical fix to the orb.
   *
   * ★ ONE DECLARATION, FOUR SURFACES. It is set on the PANEL so it inherits to the
   * field, and it also governs the caret, the text SELECTION highlight and any future
   * native control the bar gains. ⚠ It must NOT be moved onto `.ct-orb-input` alone —
   * selection and scrollbar rendering are decided from the scheme of the element's
   * own subtree, and the caption/state text would be left on light-mode defaults.
   *
   * ⚠ IT IS SAFE FOR EVERY PALETTE, INCLUDING THE LIGHT ONES. `color-scheme` changes
   * UA DEFAULTS, not author values: Ivory already sets `--ct-orb-text` to a near-black
   * and every declaration in this file keeps its own `var()`, so a light theme keeps
   * its light look and merely stops the browser imposing a white field underneath it. */
  color-scheme: dark;
  /* ══ ★ THE 67px DEAD BAND IS GONE (2026-09-22, v2) ══════════════════════════
   *
   * v1 of the dock reserved `padding-bottom: calc(var(--ct-orb-size) + 11px)` —
   * 67px — so the input could never slide under the orb's portrait. The visitor
   * reported the consequence immediately: "the size of the caption window is too
   * high, the past one was good."
   *
   * MEASURED, and it was 67px of pure waste: at 1440x900 the resting bar was
   * 145.5px tall, of which 67px was this reserve. But the input row ended at
   * y=812 while the orb's top edge was y=826 — the input was ALREADY 14px clear
   * of the orb with no reserve at all. The padding was protecting against an
   * overlap that the flex layout had already prevented.
   *
   * ★ WHY REMOVING IT IS SAFE, AND WHAT NOW GUARANTEES THE ORIGINAL PROPERTY.
   * The property v1 was reaching for — "she may never cover her own input" — is
   * real and is NOT abandoned. It is now held by the layout instead of by empty
   * space: the input row is a flow sibling ABOVE the bar's bottom edge, and the
   * orb is anchored to that bottom-right CORNER. The corner geometry is what
   * keeps them apart, and `V2` (`orb_never_covers_the_input`) measures it against
   * the real rendered boxes on every run. A reserve was a proxy; the assertion is
   * the guarantee.
   *
   * ⚠ If a future change makes the input row span the bar's FULL width, the input
   * WILL reach the corner and this reasoning no longer holds. In that case the
   * input needs `padding-right: calc(var(--ct-orb-size) + 8px)` — reserve the
   * space where the collision would happen, not as a band across the whole bar.
   */
  padding-bottom: 11px;
  font-size: 14px;
  font-family: inherit;
  color: #fff;
  /* ── THE ONE SURFACE ────────────────────────────────────────────────────
   * Was five separate backgrounds (`rgba(18,18,20,0.92)` on the caption and the
   * input, `0.85` on the mute, none on the panel). One translucent slab now
   * holds all of them, so the bar is a single object rather than a pile.
   * `backdrop-filter` is the glass: the page shows through faintly, which is
   * what keeps a fixed bar from reading as a black hole on a light page. */
  background: var(--ct-orb-ink);
  backdrop-filter: blur(14px) saturate(1.15);
  -webkit-backdrop-filter: blur(14px) saturate(1.15);
  border: 1px solid rgba(255, 255, 255, 0.10);
  /* ══ ★ THE BAR IS A RAISED SLAB, NOT A FLAT RECTANGLE (2026-09-22, v6b) ════
   *
   *     "make the whole caption strip skinnier and 3d looking"
   *
   * "3D looking" is three separate optical cues, and a bar needs all three or it just
   * looks DARKER rather than RAISED:
   *
   *   1. A LIGHT EDGE ON TOP, A DARK EDGE BELOW. That is how a physical slab catches
   *      light from above. An `inset` shadow does it without adding any elements, and
   *      the inner highlight must be very low alpha (0.10) — anything stronger reads
   *      as a glass tube rather than a surface.
   *   2. A DROP SHADOW WITH DEPTH. The old single 40px blur was one soft smudge; a
   *      layered shadow (a tight 2px contact shadow plus a wide ambient one) is what
   *      the eye reads as "this is floating above the page". The tight pass is what
   *      actually sells the lift — a wide blur alone looks like a stain.
   *   3. A SUBTLE VERTICAL GRADIENT. Flat fill + bevel still reads as a sticker.
   *      `180deg` from slightly-lighter to slightly-darker gives the face of the slab
   *      a direction, which is what defines "top" and "bottom" at a glance.
   *
   * ⚠ THE GRADIENT SITS OVER `var(--ct-orb-ink)`, NOT INSTEAD OF IT. The ink is the
   * themed translucent base (the page shows through it); the gradient is a white/black
   * wash ON TOP of that base, so the theme still governs the colour and this only
   * adds the light direction. Replacing the base with opaque greys would have made
   * the bar theme-blind and killed the backdrop blur it depends on for legibility.
   *
   * ⚠ AND THE HIGHLIGHT DOES NOT REPLACE THE BORDER. The border is the crisp edge;
   * the inset highlight is the LIT edge just inside it. Both are needed — the border
   * gives the shape at a glance, the highlight gives the material. */
  /* ★★ THE METALLIC STACK (v8) — FOUR LAYERS, THEMED, WITH LITERAL FALLBACKS.
   *
   * Painted BOTTOM-UP, which is the order a metal surface actually behaves in:
   *   1. the themed base fill (`--ct-orb-ink`), opaque enough to guarantee contrast;
   *   2. a horizontal sheen band, offset to the left so the light has a SOURCE — a
   *      metal face reading its light from nowhere is the flat 'sticker' look;
   *   3. the 180deg vertical gradient (metal-a → metal-b → metal-c), which is what
   *      gives the face a top and a bottom;
   *   4. the bevel, on `box-shadow` below.
   *
   * ⚠⚠ EVERY `var()` CARRIES A LITERAL FALLBACK, AND THAT IS NOT REDUNDANT. An
   * unresolvable `var()` in a `linear-gradient()` makes the WHOLE declaration
   * invalid — the browser drops it silently, with no console error, and the panel
   * renders flat. Since a theme is emitted by PHP (absent on a cached page, a DB
   * reset, or a PHP error), the literal fallback is what keeps the orb correct in
   * exactly those cases. The `:root` defaults are the second layer of the same
   * guarantee. ★ Belt and braces on purpose: this failure has no message. */
  background-image:
    linear-gradient(
      100deg,
      var(--ct-panel-sheen, rgba(255, 255, 255, 0.05)) 0%,
      rgba(255, 255, 255, 0) 38%,
      rgba(255, 255, 255, 0) 62%,
      var(--ct-panel-sheen, rgba(255, 255, 255, 0.05)) 100%
    ),
    linear-gradient(
      180deg,
      var(--ct-panel-metal-a, rgba(58, 58, 66, 0.92)) 0%,
      var(--ct-panel-metal-b, rgba(30, 30, 34, 0.90)) 46%,
      var(--ct-panel-metal-c, rgba(10, 10, 12, 0.94)) 100%
    );
  box-shadow:
    /* the lit top edge + the shaded bottom edge (the bevel) */
    inset 0 1px 0 var(--ct-panel-edge, rgba(255, 255, 255, 0.12)),
    inset 0 -1px 0 rgba(0, 0, 0, 0.28),
    /* the contact shadow: tight and dark, this is the "lift" */
    0 2px 4px rgba(0, 0, 0, 0.30),
    /* the ambient shadow: wide and soft, this is the "floating" */
    0 14px 40px rgba(0, 0, 0, 0.42);
  animation: ct-orb-panel-in 0.24s cubic-bezier(0.22, 1, 0.36, 1);
}

@keyframes ct-orb-panel-in {
  from { opacity: 0; transform: translateY(10px) scale(0.98); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

/* ══ ★ `.ct-orb-no-anim` — THE ONE-FRAME MEASUREMENT CLASS (2026-09-25) ══════════
 *
 * WHY IT EXISTS. `_syncDock()` sizes the orb from the panel's CONTENT box, and the
 * panel's `padding`/`gap`/`min-height` are animated over 0.22s by the tier rules. A
 * `getComputedStyle()` read taken while that transition runs returns the INTERPOLATED
 * value by definition — and no arithmetic can recover the destination from it. So the
 * orb was sized for a bar that was still moving, and a consumer measuring on its own
 * clock (the QA harness; anything reading the DOM in that fifth of a second) saw her
 * a few px off.
 *
 * WHAT IT DOES. `_syncDock()` adds this class, reads the panel (which the browser now
 * computes at the FINAL tier values), and removes it again in the same frame — before
 * paint. The visitor never sees an un-animated bar; the measurement is simply taken
 * against the destination instead of the journey.
 *
 * ⚠ IT MUST BE `!important` TO WIN. The panel's own transition is declared on the base
 * `.ct-orb-panel` rule, and the tier blocks only change `padding`/`gap` — a bare class
 * here would lose to those and the class would silently do nothing (the same trap the
 * `[hidden]` rule documents). This is the second and last place in this file where
 * `!important` is the correct tool rather than a shortcut.
 *
 * ⚠ IT IS A MEASUREMENT UTILITY, NOT A STYLE. Never add it to markup, never leave it
 * on a node, and never give it visual properties — its entire contract is "for this one
 * read, the layout is final". `.ct-orb-no-anim` on a resting page would make the orb
 * snap instead of glide, which is the opposite of the requested behaviour. */
.ct-orb-no-anim,
.ct-orb-no-anim * {
  transition: none !important;
  animation-duration: 0s !important;
}

/* ══ ★★ THE PANEL MUST ACTUALLY OBEY `hidden` (v8) — THE BUG THIS FILE SHIPPED ══
 *
 * ── THE REPORT ────────────────────────────────────────────────────────
 *     "it is not minimized at the start, and there is no minimizing when
 *      clicking minus sign"
 *
 * ── THE MEASUREMENT (this is the whole story) ────────────────────────────────
 *     DIV.ct-orb-panel  rect:[978, 802, 444, 80]  hidden: TRUE  display: flex
 *
 * ⚠ THE PANEL WAS PAINTED ON EVERY PAGE, PERMANENTLY — including at load, and it
 * stayed painted after `−`. A 444×80 dark slab on every page of the site.
 *
 * ★ THE CAUSE: `hidden` IS ONLY A SUGGESTION. It works by applying
 * `display: none`, so **ANY explicit `display` rule beats it** — and the base
 * `.ct-orb-panel` block sets `display: flex`. There was **no `[hidden]` rule for
 * the panel anywhere in this stylesheet** (only `.ct-orb-options[hidden]` existed),
 * so the attribute was toggled by JS for months while changing nothing on screen.
 *
 * ⚠ SO THE MINUS WAS NEVER BROKEN. `_collapse()` set `hidden`, restored the peek
 * and put her to sleep, exactly as designed — but toggling an attribute that has NO
 * CSS EFFECT cannot change a single pixel. **The button worked; the rule it depended
 * on did not exist.**
 *
 * ── WHY `!important` IS CORRECT HERE, NOT LAZINESS ───────────────────────
 * The base rule sets `display: flex` (specificity 0,1,0), and the dock publishes
 * ELEVEN `#ct-orb-panel …`-prefixed rules (specificity 1,1,0) that also declare
 * layout. A plain `.ct-orb-panel[hidden]` (0,2,0) would beat the base rule but NOT
 * the id-prefixed ones. This is the one place in the file where the selector has to
 * win against everything, and `!important` states that intent in one line instead of
 * escalating specificity forever. ⚠ It is safe precisely because `hidden` is a
 * BINARY, JS-controlled state: nothing else should ever be able to override it.
 *
 * ★ KEEP THIS RULE ADJACENT TO THE BASE BLOCK. It is the base block's escape hatch,
 * and separating them is how the original omission happened.
 *
 * ⚠⚠ AND THE LESSON IS BIGGER THAN THE RULE. The QA suite asserted
 * `panel.hasAttribute('hidden')` and reported "minimised" — **green tests over a
 * visibly broken page**, because the probe read the ATTRIBUTE while the browser
 * obeys the COMPUTED DISPLAY. An attribute states INTENT; a computed value is the
 * OUTCOME. Any future lock about visibility must measure the outcome.
 * See `qa/orb_visual_test.py` V24/V25.
 */
.ct-orb-panel[hidden] {
  display: none !important;
}

/* …and the same for every other element this file hides via the attribute, so the
   next `hidden` consumer cannot repeat the omission. ⚠ `.ct-orb-options[hidden]`
   already had its own rule and keeps it; these are the ones that were missing. */
.ct-orb-panel[hidden] .ct-orb-head,
.ct-orb-panel[hidden] .ct-orb-caption,
.ct-orb-panel[hidden] .ct-orb-state,
.ct-orb-panel[hidden] .ct-orb-form {
  display: none !important;
}

/* ══════════════════════════════════════════════════════════════════════════
 * ★ THE DOCKED ORB — SHE SPANS THE BAR'S FULL HEIGHT (2026-09-22, v2)
 *
 * ── THE COMPLAINT, IN TWO HALVES ──────────────────────────────────────────
 *     "the size of the caption window is too high, the past one was good, and
 *      the orb suppose to be inside the caption window fully covering the whole
 *      height and stay on right completing the circle as it is now."
 *
 * v1 of the dock fixed the WIDTH (the bar became flush with the orb, gaining
 * 64px) but left the orb sitting at the BOTTOM-RIGHT CORNER while the bar rose
 * 145px above it. The orb was 56px in a 145px bar: the "circle completing the
 * rectangle" only read in the bottom 38% of the right edge, and the rest of that
 * edge was a straight line that had nothing to do with her.
 *
 * ── THE RULE ──────────────────────────────────────────────────────────────
 * Dock her to the ORB'S OWN axis and let her fill the bar's content height, so
 * the right cap is a genuine full-height semicircle — a half-circle is exactly
 * what a circle looks like when a rectangle is built around it. The bar's height
 * is now DERIVED from her (`--ct-orb-size` + the panel's own padding), which is
 * the same contract idea as the width: nothing about this surface is allowed to
 * be a number that was typed in by hand.
 *
 *     bar content height = max(--ct-orb-size, her reply + input + status)
 *     orb height         = 100% of that content box
 *     orb vertical axis  = the bar's vertical axis (top: 50% + translateY(-50%))
 *
 * ── ★ WHY THIS IS DESKTOP-ONLY, AND HOW THAT IS ENFORCED ──────────────────
 * On a phone (≤480px) the panel is a FULL-SCREEN STAGE and the orb is a centred
 * 112px HERO floating in the top third — `_stage()` adds `.ct-orb-staged` to both
 * elements. Making the orb `height: 100%` there would turn her into a 700px
 * giant filling the whole sheet. The media query is therefore load-bearing, not
 * tidy: `min-width: 481px` is the exact complement of the stage's breakpoint, and
 * `V1 the_desktop_dock_does_not_leak_onto_the_stage[mobile]` measures the result
 * on a real 390px viewport so a future edit cannot quietly widen this rule.
 *
 * ⚠ `--ct-orb-size` itself is NOT changed (still 56px). The docked orb simply
 * fills a taller box. That distinction matters because `_isPhoneWidth()` and the
 * staged hero both reason about `--ct-orb-size`, and one variable serving two
 * contexts is how the phone stage would start drifting.
 * ══════════════════════════════════════════════════════════════════════════ */
@media (min-width: 481px) {
  #ct-avp-orb.ct-orb-dock {
    /* ★ ANCHOR FROM THE BOTTOM, NOT WITH `top: 50%` (2026-09-22, v2).
     *
     * The first attempt used `top: 50%; transform: translateY(-50%)` — the usual
     * way to centre an element. It measured 411px from the top when the bar's own
     * band was 780..882, i.e. **393px too high**, because `top: 50%` on a
     * `position: fixed` element resolves against the VIEWPORT (900/2 = 450), and
     * this orb is a SIBLING of the panel, not a child of it. A fixed sibling has
     * no access to the panel's box, so no `top` percentage can ever express
     * "centred on the bar".
     *
     * So the anchor is the bar's own BOTTOM edge — the one edge these two
     * elements already share, and the one the dock is built on.
     *
     *     orb bottom = --ct-orb-inset + the bar's own padding   (its CONTENT edge)
     *     orb size   = --ct-orb-height           (published by `_syncDock()`)
     *     → she is vertically centred on the bar BY CONSTRUCTION, because her box
     *       and the bar's content box are the same height and the same edge.
     *
     * ⚠ IT MUST BE THE CONTENT EDGE, NOT THE BORDER EDGE (measured, not assumed).
     * The first version anchored her to `--ct-orb-inset`, which is the bar's
     * BORDER bottom — 11px below its content bottom, because the panel carries
     * `padding: 11px`. Since her height comes from the CONTENT box, anchoring her
     * bottom to the border edge pushed her 11px low: `V15` reported
     * orbCentreY=842 against barContentCentreY=831.
     *
     * ★ AND IT MUST FOLLOW THE PANEL'S PADDING, NOT REPEAT ITS OLD VALUE
     * (2026-09-22, v4 — found by the thin strip). That fix hard-coded `+ 11px`,
     * which was correct only while the panel's padding WAS 11px. The thin strip
     * drops it to 6px while resting, so the hard-coded 11px over-offset her by
     * exactly the 5px difference and `V15` reported orbCentreY=842 against
     * barContentCentreY=847. Reading `--ct-orb-pad` — declared beside `padding` on
     * the panel and overridden WITH it in the resting rule — means the anchor and
     * the padding cannot disagree again.
     *
     * ⚠ The `11px` fallback matters: `--ct-orb-pad` belongs to the panel and this
     * rule belongs to the ORB, a body-level sibling. If the panel never builds,
     * `var()` must still yield the expanded padding rather than 0.
     *
     * ── ★ SHE IS CAPPED, AND THAT CAP IS A FEEDBACK-BREAKER (found by measuring)
     * `--ct-orb-height` is the bar's content height, so a growing reply grows the
     * orb — which is what "fully covering the whole height" asks for. But a
     * CIRCLE of diameter D also extends D to the LEFT, while she is anchored to
     * the RIGHT edge. Past a certain height the clearance the text must leave is
     * wider than the bar, the caption re-wraps taller, and the two chase each
     * other: the probe measured her left edge at **1307.1** with the caption's
     * text ending at **1328** — a 21px overlap that no amount of extra gutter
     * could settle, because widening the gutter grew the bar, which grew her.
     *
     * So her diameter is bounded by `--ct-orb-max` (68px). Beyond that she stops
     * growing and simply sits centred on a taller bar, which is the honest
     * reading anyway: past ~68px a portrait at the end of a bar stops looking
     * like a cap and starts competing with the content. The bar is free to grow
     * with her reply; she is not obligated to match it.
     */
    bottom: calc(max(var(--ct-orb-inset), env(safe-area-inset-bottom, 0px))
                 + var(--ct-orb-pad, 11px)
                 - var(--ct-orb-y));
    top: auto;
    /* ⚠⚠ THE DRAG OFFSET MUST BE REPEATED HERE (2026-09-25), AND FORGETTING IT IS WHY
     * THE DRAG APPEARED TO DO NOTHING. The base `#ct-avp-orb` rule carries the offset,
     * but THIS rule re-declares `right`/`bottom` to express the dock anchor (bottom
     * padding + the bar's own padding), and it is MORE SPECIFIC — so it silently won
     * the cascade and discarded the offset entirely.
     *
     * MEASURED failure: `--ct-orb-x` was correctly `-90px` and `_dockX` was `-90`, yet
     * the computed `right` stayed `18px` and her left edge never moved. Every value in
     * the pipeline was right except the one that paints — the classic "two independent
     * keys that must agree will eventually disagree" shape.
     *
     * ⚠ SO EVERY RULE THAT SETS THESE TWO ANCHORS MUST CARRY `- var(--ct-orb-x/y)`.
     * There are FOUR in this file: the base orb rule, this dock rule, the panel, and the
     * peek pill. A grep for `--ct-orb-x` is the way to confirm none was missed. */
    right: calc(max(var(--ct-orb-inset), env(safe-area-inset-right, 0px))
                - var(--ct-orb-x));
    --ct-orb-max: 68px;
    /* ══ ★ SHE TRACKS THE BAR — AND SHE MUST NOT ANIMATE SEPARATELY (2026-09-25) ══
     *
     * ⚠⚠ THERE IS DELIBERATELY **NO** `transition` HERE. An earlier attempt at this
     * fix gave the orb the panel's own 0.22s clock for `width`/`height`, on the
     * reasoning that the two should glide together. It made things WORSE, and the
     * measurement says exactly why:
     *
     *     panel content box : 56px   (settled)
     *     orb border box    : 60.7px (still animating toward 56)
     *
     * Two independent animations cannot be kept in step by giving them the same
     * duration — the panel's clock starts when ITS properties change, the orb's when
     * `--ct-orb-height` is rewritten, and those are different frames. So for a fifth
     * of a second she is a circle that does not match the strip she is supposed to
     * cap, which is the precise fault `V15` exists to catch.
     *
     * ★ THE HONEST ARRANGEMENT IS ONE MOVING PART. The panel glides; the orb is sized
     * from a variable that is already correct, so she lands on the bar's new content
     * box the moment the number is published. Any residual difference is the panel
     * mid-flight, which is a single animation the eye reads as one movement — rather
     * than two objects disagreeing about their shared edge.
     *
     * ⚠ AND THIS IS WHY `prefers-reduced-motion` NEEDS NOTHING FOR THE ORB: with no
     * transition to suppress, there is no preference to honour here. The panel's own
     * block already handles its half. */
    /* ★ SHE IS AT LEAST HER NORMAL SIZE, AND NEVER TALLER THAN THE BAR (2026-09-22, v3).
     *
     * `--ct-orb-height` is the bar's measured CONTENT height. Since v3 the resting
     * bar is a single row (`data-ct-orb-resting`), so that content height is ~56px
     * — one input row — and a bare `min(height, 68px)` would shrink her to that
     * 56px and make the portrait visibly smaller than the un-docked orb she
     * replaces. Measured: orbH=58 while resting, against 68 while she was talking.
     *
     * So the diameter is clamped at BOTH ends: at least `--ct-orb-size` (her
     * normal 56px, so she never becomes a shrunken version of herself) and at most
     * `--ct-orb-max` (so a tall bar does not grow her into a competing element).
     * Between those two the bar and the orb ARE the same box, which is the
     * "she fills the bar" behaviour the visitor asked for: a one-row bar gives
     * 56px of content, so she is exactly 56px and the right cap is a true
     * semicircle. */
    height: clamp(var(--ct-orb-size),
                  var(--ct-orb-height, var(--ct-orb-size)),
                  var(--ct-orb-max));
    width: clamp(var(--ct-orb-size),
                 var(--ct-orb-height, var(--ct-orb-size)),
                 var(--ct-orb-max));
    /* ★ WHEN SHE IS CAPPED SHE MUST STILL BE CENTRED (measured).
     *
     * The bottom anchor centres her automatically *only while she fills the bar*
     * — the two boxes are then the same height and share an edge. Once the cap
     * engages (a tall bar, a long reply) she is a smaller circle on a taller bar,
     * and a bottom-anchored circle sits LOW: `V15` measured a 6px offset
     * (orbCentreY=837 vs barContentCentreY=831).
     *
     * The correction is the difference between the bar's content height and her
     * own clamped diameter, halved — expressed in CSS so it is always in step with
     * both numbers, and it evaluates to exactly 0px whenever she does fill the bar
     * (the common, one-row case), so that layout is untouched.
     *
     *     offset = (barContent − orbDiameter) / 2
     *
     * ⚠ The lower clamp bound matters here too: without it a 56px content box would
     * give a negative offset and push her off the bar.
     */
    margin-bottom: calc((var(--ct-orb-height, var(--ct-orb-size))
                         - clamp(var(--ct-orb-size),
                                 var(--ct-orb-height, var(--ct-orb-size)),
                                 var(--ct-orb-max))) / 2);
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
  }
  /* The hover/focus scale stays a pure scale now that no centring transform is
     involved — but it is written explicitly so a later re-introduction of a
     translate cannot silently drop the hover feedback. */
  #ct-avp-orb.ct-orb-dock:hover,
  #ct-avp-orb.ct-orb-dock:focus-visible {
    transform: scale(1.04);
  }
  /* ★ THE RING FRAMES HER, IT DOES NOT HALO HER (2026-09-22, v5).
   *
   * MEASURED in the visitor's screenshot state (751x352): the ring's bounding box
   * was **104x104 around a 68px portrait** — and at rest 87.8px around 58px. That
   * gap is what makes the right end of the bar feel unbalanced: the animation
   * surface is nearly twice the width of the thing it is animating.
   *
   * Two causes, and both are fixed here rather than by deleting the animation:
   *   1. `inset: -7px` was tuned when the docked orb was ~78px. On a 56-58px orb it
   *      is proportionally too wide.
   *   2. The ripple's peak `scale(1.28)` is measured mid-flight, so the ring's
   *      apparent footprint peaks at 1.28x its inset box.
   * The inset is tightened and the peak reduced (see `ct-orb-ripple`), so the pulse
   * still reads as "sound leaving her" without the ring competing with her face.
   *
   * ⚠ AND THE RING IS SIZED OFF *HER*, NOT OFF THE BAR (2026-09-22, v5). MEASURED:
   * the ring was 104x104 around a 68px portrait — 18px of overhang each side. It is
   * driven by `--ct-orb-size` (56px) rather than the orb's ACTUAL diameter, so when
   * the clamp grew her to 68px the ring stayed tuned for a smaller orb. The overhang
   * is now a PERCENTAGE of `--ct-orb-size`, so the gap scales with her instead of
   * being a fixed pixel value that is right at exactly one size — and it reads as
   * part of her, which is what a halo around a face must do.
   *
   * ⚠ THE ANIMATION IS NOT REMOVED. It is the ONLY per-state signal that survives
   * colour-blindness and distance — the MOTION LANGUAGE block above says exactly
   * that. This is calibration, not deletion. */
  #ct-avp-orb.ct-orb-dock .ct-avp-ring {
    /* 9% of 56px = ~5px, and it grows with her if the clamp ever changes. */
    inset: calc(var(--ct-orb-size) * -0.09);
    border-width: 2px;
  }
  #ct-avp-orb.ct-orb-dock .ct-avp-icon { font-size: 30px; }

  /* ── ★ THE RESTING BAR IS ONE ROW (2026-09-22, v3) ─────────────────────────
   *
   * ── THE COMPLAINT, AFTER v2 ───────────────────────────────────────────────
   *     "102 px resting bar is still too tall. i will go for 78 px."
   *
   * MEASURED, and the v2 bar was 102px like this:
   *
   *     padding (11 + 11)             22px
   *     .ct-orb-state  "I'm listening…" 15.6px   ← the second row
   *     flex `gap`                      8px
   *     .ct-orb-form (the input row)   44px
   *     ────────────────────────────────────
   *                                   102px
   *
   * 78px is `--ct-orb-size` (56) + 22 padding: **exactly one row plus the
   * portrait's breathing room.** So the target cannot be met by shaving pixels —
   * the STATUS ROW has to leave the resting bar, and the 8px gap with it. That
   * lands the bar at ~66px, which is 78px-compliant with slack to spare: 78px
   * becomes a CEILING (the `min-height` floor), not a value the bar sits on.
   *
   * ── ★ WHY REMOVING THE STATUS IS NOT A LOSS ───────────────────────────────
   * The four status strings are *redundant on the resting bar*, three ways:
   *   1. The orb ALREADY says it, non-verbally. `ct-orb.css` documents a "motion
   *      language" for exactly this reason — LISTENING breathes (green),
   *      THINKING travels (yellow), SPEAKING ripples (gold) — chosen so the state
   *      is legible from across a room and without colour. The ring is the
   *      signal; the sentence was a caption for the signal.
   *   2. `_showState()` CLEARS THE CAPTION when it writes, so the status row and
   *      her words never appear together anyway. The row was never carrying
   *      information alongside anything — it held the floor alone.
   *   3. On a phone it is genuinely the only textual signal (no hover, and the
   *      orb is a distant hero), so it STAYS THERE. This rule is desktop-only.
   *
   * ── HOW THE COLLAPSE IS DRIVEN (CSS decides, JS informs) ──────────────────
   * `:empty` cannot express this: the status element is written by JS, so
   * `.ct-orb-head` always has a child and `:empty` never matches. Instead the
   * loop sets `data-ct-orb-resting="1"` on the panel when there is nothing to
   * read, and this rule keys off that. Keeping the decision in CSS means the
   * layout cannot drift from the JS's idea of "resting" — the attribute is a
   * fact, the styling is a consequence.
   *
   * ⚠ DESKTOP ONLY, and that is enforced by the media query, not by hope. The
   * phone stage keeps its status row (`V6 status_uses_the_slim_row[mobile]`
   * locks that) because there the ring is far away and the text is all there is.
   */
  .ct-orb-panel[data-ct-orb-resting="1"] {
    gap: 0;
    /* ★★ THE THIN STRIP, NARROWED AGAIN (2026-09-22, v6b) — 6px → 4px.
     *
     * The visitor asked to "make the whole caption strip skinnier". At rest the bar
     * is one row, so its height is ENTIRELY padding + the input row + the 2px border,
     * and the padding is the only part of that which buys nothing: it is frame around
     * a 56px portrait that already reads as the bar's right cap.
     *
     * MEASURED: 6px → 4px takes the resting strip from **70px to 66px**, and the
     * portrait stays 56px — so she is now 85% of the strip's height, up from 80%. On
     * a bar this short, 4px of frame is still visibly a frame; the transition to a
     * genuinely frameless stripe is a different design (see the whisper tier) and is
     * not what "skinnier" asked for.
     *
     * ⚠ 3px (was 4px) — AND THAT IS THE FLOOR, ONE PX ABOVE THE DOCUMENTED LIMIT.
     * The block above records 4px as "THE FLOOR, NOT A ROUNDING... do not chase 2px
     * without looking at a screenshot". Raising the *bar* beyond that is therefore not
     * where the remaining height can come from — the input row's own `min-height` is
     * (see the slim-field block), and that is what this change pairs with.
     *
     * ⚠ `padding` IS OVERRIDDEN AS A WHOLE, NOT `padding-block`. A one-axis override
     * would leave the horizontal padding behind and the input would no longer line up
     * with the caption that appears when the bar expands.
     *
     * ⚠⚠ AND THE PADDING MUST BE REPUBLISHED TO THE ORB. `_syncDock()` sets
     * `--ct-orb-pad` on the orb from this same measured value, because the orb is a
     * SIBLING of this panel and a custom property cannot inherit sideways. A padding
     * change that is not republished leaves the orb off-centre by exactly the
     * difference (measured once before: 11 − 6 = the 5px shift `V15` caught). */
    padding: 3px;
  }
  .ct-orb-panel[data-ct-orb-resting="1"] .ct-orb-head {
    display: none;
  }

  /* ── ★ THE WHISPER TIER — THE FRAME DISSOLVES (2026-09-22, v5) ─────────────
   *
   * ── THE VISITOR'S REPORT (with a screenshot) ──────────────────────────────
   *     "needs more balancing and logic size of caption windows, make it
   *      thinner, like a thin stripe saying text or talk... not box visible.
   *      and than expandible."
   *
   * ── WHAT WAS ACTUALLY WRONG: THERE WAS NO MIDDLE TIER ─────────────────────
   * MEASURED at 751x352 with a three-line reply — the bar was **157.5px**:
   *
   *     padding 11 + 11                22px
   *     caption (3 lines)              60.9px
   *     status row ("Let me think…")   15.6px
   *     flex gap                        8px
   *     input row                      44px
   *
   * The RESTING strip (70px) was already right. The fault was the CLIFF: the
   * instant she said anything the bar jumped 70px → 157px and drew the full slab
   * — `rgba(18,18,20,0.82)`, a 1px border, a 40px shadow and a 16px/999px radius.
   * A three-word status line therefore got a 157px black box, which is exactly
   * the "not box visible" the visitor objected to.
   *
   * ── THE FIX: A TIER, AND A FRAME THAT FADES ───────────────────────────────
   * A short reply now lands in a WHISPER: at most two lines of her words, no
   * status row, tighter padding — and the frame DISSOLVES rather than merely
   * shrinking. The panel stops being defined by its edges and starts being
   * defined by its content, which is what "like a thin stripe ... not box
   * visible" asks for. The full frame returns for a genuinely long reply, where
   * a container is honest and helpful.
   *
   * ⚠ WHISPER IS DRIVEN BY MEASURED TEXT, NOT BY STATE OR WORD COUNT. The loop
   * sets `data-ct-orb-whisper="1"` from the caption's real rendered height (see
   * `_syncDock()`), so a 3-word status and a 2-line reply both whisper while a
   * 6-line answer expands. A word-count rule would misjudge long words and
   * narrow viewports, which is the same mistake the caption's rolling window
   * already documents.
   *
   * ⚠ DESKTOP ONLY. On a phone the panel is a full-screen stage with its own
   * typography and padding; a dissolved frame there would remove the only visual
   * boundary on the screen. `V6`/`V13` lock the stage and are untouched.
   */
  .ct-orb-panel[data-ct-orb-whisper="1"] {
    /* The frame fades: the page shows through and the text floats on it. */
    /* ★★ THEMED (v8) — AND THIS WAS A REAL BUG, FOUND BY LOOKING AT A SCREENSHOT.
     *
     * It used to be the FLAT LITERAL `rgba(18, 18, 20, 0.34)` — a dark ink hard-coded
     * here, which meant the whisper tier IGNORED THE THEME ENTIRELY. On the shipped
     * Ink & Gold theme that is invisible, because the literal happens to match. On a
     * LIGHT theme it was broken outright: Ivory sets `--ct-orb-ink` to a near-white and
     * `--ct-orb-text` to `#1a1a1c`, so the caption rendered DARK TEXT ON A DARK PANEL —
     * very nearly unreadable, and the admin would have had no idea which layer did it.
     *
     * ★★ THE CHOSEN COLOUR MUST SURVIVE THE FADE (v9) — THE PREVIOUS ATTEMPT DID NOT.
     *
     * REPORTED as "the theme changes to the default ... at certain responses by the
     * hostess, and after finished with the response, goes back to selected one".
     * The theme was NOT changing: `--ct-panel-metal-a` was measured constant through
     * an entire reply cycle. The whisper tier was DESTROYING THE PIGMENT and letting
     * the page's own colour pour through.
     *
     * ── WHY 42% ALPHA WAS THE WRONG MECHANISM ─────────────────────────────────
     * It faded the BASE FILL — i.e. it bought transparency by throwing the colour
     * away. Rendered on a white page, the ten themes were MEASURED to collapse into
     * near-identical greys:
     *
     *     burgundy  → grey-lavender      ink-gold   → grey
     *     emerald   → grey               ivory      → INVISIBLE (white on white)
     *
     * The visitor chose Burgundy, got wine red at rest, and watched it turn grey the
     * moment she spoke — which reads exactly like "it went back to Ink & Gold".
     *
     * ── THE FIX: GET THE DISSOLVE FROM THE SURROUNDINGS, NOT FROM THE PIGMENT ──
     * The tier's identity is that it reads as a thin strip rather than a slab. That
     * is delivered by the SOFT EDGE, the reduced shadow and the blur — none of which
     * need the fill to be 34% opaque. So the base keeps its colour (~0.72) and the
     * dissolve comes from the layers around it.
     *
     * ⚠ 0.72 IS CHOSEN FROM MEASUREMENT, NOT TASTE. At 0.42 every theme collapsed to
     * grey on white; at 0.72 the hue is unmistakable while the page still shows
     * through, which is the visible property the visitor actually asked for.
     *
     * ⚠ IT IS STILL `color-mix()` RATHER THAN A HARD-CODED ALPHA, because the themes'
     * own inks differ (0.82–0.97) and a literal would flatten that variation. The
     * `var()` fallback chain keeps an engine without `color-mix()` on a correct dark
     * colour rather than the old pale literal.
     */
    background: color-mix(in srgb, var(--ct-orb-ink, rgba(18, 18, 20, 0.82)) 74%, transparent);
    border-color: rgba(255, 255, 255, 0.05);
    /* ⚠ THE 3D CUES SURVIVE THE FADE (v6b). The visitor asked for a strip that is
     * "skinnier and 3d looking", and the whisper tier is the one that carries a real
     * reply — so letting it fall back to a flat smudge would undo the request exactly
     * when the bar is most visible.
     *
     * ★ THEY ARE SOFTER, NOT ABSENT. The whisper tier is defined by the frame
     * DISSOLVING (a reduced alpha, a soft edge, a held-back shadow), so the bevel and
     * shadow are held back to about half strength: a hard lit edge on a partly
     * transparent surface reads as a glass tube rather than a raised slab, which is
     * the opposite of the ask. The gradient is kept because it is what gives the face
     * a light direction, and it is already low-alpha (< 0.06) so it survives the fade
     * without becoming a panel.
     *
     * ⚠ ORDER MATTERS: `background-image` must sit AFTER `background` or the
     * shorthand resets the gradient to `none` — which is exactly what the first
     * version of this tier measured (`bgImage: 'none'`).
     *
     * ★★ THE BLACK WASH DESATURATED THE THEME (v9) — IT IS GONE. The third stop used
     * to be a flat `rgba(0, 0, 0, 0.07)`, and while the base sat at 42% alpha that
     * black mixed toward NEUTRAL — which is precisely how burgundy rendered grey and
     * ivory rendered invisible. A neutral wash over a translucent surface can only
     * grey it, so it fought the themed fill it was supposedly decorating. The light
     * direction is carried by the WHITE stops (`0.055` → `0.010`), which read as a
     * soft top-lit face on any hue; the strip's bottom edge is already defined by the
     * `inset 0 -1px 0` shadow below, which is hue-neutral.
     *
     * ══ ★★★ v10 — THE TIER WAS DELETING THE THEME'S METAL, NOT JUST FADING IT ══
     *
     * REPORTED (again, and this time with the diagnostic that cracked it):
     *     "for any question other than the agent related question … if i ask how many
     *      languages, the theme changes to ink and gold for the answer, and when quiet
     *      comes back to burgundy, which i have selected at the backend. but if i ask
     *      for cleopatra and any other agent, it keeps the selected theme."
     *
     * ⚠ THE OBSERVATION WAS RIGHT AND THE EXPLANATION WAS NOT: it is not about AGENTS.
     * It tracks REPLY LENGTH. An agent bio is long → `whisper="0"` → the full slab,
     * gradient and all. "How many languages" is short → `whisper="1"` → THIS tier.
     * Two different questions about the same person would have split the same way.
     *
     * ── WHY v9's FIX WAS ONLY HALF THE STACK ─────────────────────────────────
     * v9 repaired the tier's `background` (the `--ct-orb-ink` color-mix above) and the
     * measurement confirms that half is still correct — the fill IS the chosen theme's
     * ink in every palette. But the panel is painted from FOUR layers (see the base
     * rule: fill, sheen, metal gradient, bevel), and this declaration used to REPLACE
     * the base `background-image` — which carries the sheen AND the 180deg metallic
     * gradient — with a single hue-neutral white wash.
     *
     * ⚠ `background-image` DOES NOT INHERIT, so "replacing" is the correct word:
     * nothing brought the metal back. The theme's CHARACTER is carried by
     * `--ct-panel-metal-a/b/c` and by the tinted `--ct-panel-sheen`; without them the
     * strip is left with nothing but the ink and a white haze — and a flat ink plus
     * white haze looks like the SAME shape of surface in all ten palettes. On Ink &
     * Gold that is invisible, because its ink is already near-black and matches the
     * neutral look; the strip simply appears to "go back to the default", which is
     * exactly what was reported.
     *
     * MEASURED on the real orb, midnight theme, same session, two consecutive turns:
     *     whisper="1"  bg=color(srgb 0.031 0.047 0.102 / 0.65)
     *                  bgImage=linear-gradient(180deg, rgba(255,255,255,0.055) → …)
     *                          ← NO metal stops at all
     *     whisper="0"  bg=rgba(8, 12, 26, 0.88)
     *                  bgImage=…sheen…, linear-gradient(rgba(40,56,104,0.92) →
     *                          rgba(20,28,60,0.92) → rgba(5,8,18,0.96))  ← the hue
     *
     * ⚠ AND BOTH EXISTING LOCKS WERE GREEN ON THAT CODE. `orb_theme_test.py` T10 only
     * ever inspected THIS block in isolation (so it could not see the base gradient
     * being overridden), and `.dev/theme_wash_all.py` sampled `backgroundColor` ONLY —
     * never `background-image`. A lock that reads the property that did not break
     * cannot fail. T10 now asserts the metal vars are present here, and V27 in
     * `orb_visual_test.py` asserts it on the COMPUTED style of the live orb.
     *
     * ── THE FIX: RE-DECLARE THE WHOLE STACK, HELD BACK — DON'T DELETE IT ─────
     * This tier's identity is a DISSOLVED frame, so the metal is deliberately SOFTER
     * than the base rule rather than equal to it. The factor is ~62% of the base stops,
     * which keeps the strip reading as a stripe while the hue stays unmistakable — the
     * same reasoning (and the same band) as the `74%` on the fill above, so the two
     * layers soften TOGETHER and the strip dissolves as one object. Fading only the
     * fill while the material stayed at full strength is what made the earlier
     * attempts read as a colour change rather than as a dissolve.
     *
     * ⚠ THE STOP ORDER AND THE LAYER ORDER ARE COPIED FROM THE BASE RULE ON PURPOSE.
     * Keep the two declarations diffable line-for-line: the only legitimate difference
     * between them is the alpha factor. If you change one, change the other.
     *
     * ⚠⚠ EVERY `var()` KEEPS ITS LITERAL FALLBACK, for the reason the base rule
     * documents: an unresolvable `var()` inside `linear-gradient()` invalidates the
     * WHOLE declaration silently, and the panel would render flat with no console
     * error. The fallbacks below are the DEFAULT (Ink & Gold) stops scaled by the same
     * ~62%, so a page with no theme block degrades to the correct default look.
     *
     * ⚠ THE WHITE WASH STAYS ON TOP. It is what gives the strip a light direction on
     * any hue, it is the only part that was ever hue-neutral by design, and it is why
     * the "no black wash" assertion in T10 must keep passing.
     */
    background-image:
      /* layer 1 — the tinted sheen (the theme's own highlight, held back) */
      linear-gradient(
        100deg,
        var(--ct-panel-sheen, rgba(255, 255, 255, 0.03)) 0%,
        rgba(255, 255, 255, 0) 38%,
        rgba(255, 255, 255, 0) 62%,
        var(--ct-panel-sheen, rgba(255, 255, 255, 0.03)) 100%
      ),
      /* layer 2 — ★ THE THEME'S METAL, RESTORED (v10). This is the whole fix: the
         tier must RE-DECLARE the hue rather than let its own declaration delete it.
         The literal fallbacks are the base gradient's Ink & Gold stops at ~62%. */
      linear-gradient(
        180deg,
        var(--ct-panel-metal-a, rgba(36, 36, 41, 0.57)) 0%,
        var(--ct-panel-metal-b, rgba(19, 19, 21, 0.56)) 46%,
        var(--ct-panel-metal-c, rgba(6, 6, 7, 0.58)) 100%
      ),
      /* layer 3 — the white directional wash (unchanged from v9) */
      linear-gradient(
        180deg,
        rgba(255, 255, 255, 0.055) 0%,
        rgba(255, 255, 255, 0.010) 52%,
        rgba(255, 255, 255, 0.030) 100%
      );
    box-shadow:
      inset 0 1px 0 rgba(255, 255, 255, 0.07),
      inset 0 -1px 0 rgba(0, 0, 0, 0.18),
      0 2px 4px rgba(0, 0, 0, 0.18),
      0 8px 22px rgba(0, 0, 0, 0.20);
    /* ★★ SLIMMER AGAIN (2026-09-25) — 9px → 7px, and the flex gap 4px → 0.
     *
     * The visitor: "match the height of the caption windows to the orb diameter, or
     * perhaps 10% shorter, to look skinny than the orb".
     *
     * MEASURED on the real orb (1440x900, local, after her greeting):
     *     before   panel 124.6px   padding 9px   gap 4px   caption 40.6px   input 56px
     *     after    panel 112.6px   padding 7px   gap 0     caption 40.6px   input 56px
     * i.e. **−12px / −9.6%** of the tier the visitor actually sees, with
     * `textCollisions: []` confirmed at both settings.
     *
     * ⚠ IT CANNOT GO TO ~60px, AND THE REASON IS ARITHMETIC, NOT TASTE. The bar is
     *   `padding × 2 + border 2 + caption + gap + input row`, and the INPUT ROW HAS A
     *   56px FLOOR of its own — the scoped input rule sets
     *   `min-height: var(--ct-orb-size)`, declared because a shorter row was MEASURED to clip her
     *   (`orbH=56.1 barContentH=48.0`). So a bare input row already costs 56px before
     *   any frame, and a 60px bar cannot hold a caption at all. 7px+0 is therefore the
     *   honest floor for a tier that must still show two lines of her reply, and the
     *   gap can be zero because the caption and the row were never visually separate
     *   surfaces — they are two rows of one bar.
     *
     * ⚠ `gap: 0` DOES NOT MERGE THEM. They are separate flex children with their own
     *   padding (`caption: 0 12px`, row: its own), so the text keeps its breathing room
     *   from its own box rather than from the flex gap. This was checked by measurement,
     *   not by eye — see the caption/input heights above, which did not move. */
    gap: 0;
    padding: 5px;
    /* ★ THE BLUR CARRIES WHAT THE ALPHA NO LONGER DOES (v9).
     *
     * The wash used to be protected by being almost opaque; now the base keeps its
     * COLOUR (~0.72) instead, so more of the page reaches the strip. That is the
     * point — the dissolve is the requested look — but it puts legibility back on
     * the blur, and a 9px radius was tuned for a 34% fill. Raised to 14px so her
     * words keep their contrast on a busy page while the page still reads as
     * present-but-out-of-focus.
     *
     * ⚠ `-webkit-` IS REQUIRED: iOS Safari only gained unprefixed `backdrop-filter`
     * in 18, and this site's primary device is an iPhone. Without the prefix the
     * whisper tier would be legible on the dev box and unreadable on a phone.
     * ⚠ WHERE `backdrop-filter` IS UNSUPPORTED ENTIRELY the tier merely gets lighter
     * — it does not break, because the falling-back default is a solid `rgb()` only
     * if the alpha is dropped, and this declaration is additive. */
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
  }
  /* Her words lead in this tier: the status row is redundant with the orb's own
     ring motion (the documented MOTION LANGUAGE), and dropping it is what keeps a
     one-line reply from costing a second row. */
  .ct-orb-panel[data-ct-orb-whisper="1"] .ct-orb-state {
    display: none;
  }
  /* ★ ONE LINE, NOT TWO (2026-09-25). This is the promise that a short reply cannot
     produce a tall box, and `_captionLines()` reads this value from the computed style
     so the read-along's rolling window follows it automatically — there is no second
     place to keep in sync. 2 x 20.3px = 41px at line-height 1.45 with 14px text.
     ⚠ WAS 41px (TWO LINES). The visitor asked to go "as thin as possible ... short
     and logical", and the caption was the last remaining lever: the bar is
     `caption + input 44 + padding 10 + border 2`, so 1 line takes it from ~100px to
     **~76px**. MEASURED: `captionH 40.6 → 20.3`, `panelH 96.6 → 76.3`.
     ⚠ THIS IS A REAL DESIGN TRADE, NOT A FREE WIN: a two-line reply now shows its
     LAST line and rolls the earlier words through the read-along window (that is what
     `_showWindow()`/`_captionLinesFor()` already do for a 4-line cap). Nothing is lost
     — her words still arrive in step with her voice and the newest line stays in view
     — but a visitor cannot read two lines at once in this tier. A genuinely long reply
     still EXPANDS, because `--ct-orb-caption-max` on the base rule is untouched at
     82px/4 lines.
     ⚠ `_captionLines()` DERIVES ITS WINDOW FROM THIS VALUE, so lowering it here is the
     only edit needed — do not also hand-tune the JS. */
  .ct-orb-panel[data-ct-orb-whisper="1"] .ct-orb-caption {
    --ct-orb-caption-max: 21px;
  }

  /* ── ★★ THE SETTLED TIER — SHE HAS FINISHED SPEAKING, SO SHOW HER WHOLE REPLY
   * (2026-09-25). ─────────────────────────────────────────────────────────────
   *
   * ── THE REPORTED FAULT ────────────────────────────────────────────────────────
   * *"the text ... disappears, after the hostess has read it all ... I can see only
   *   'from the rich to give to the poor ... What draws you to him?' — whatever is
   *   before is not visible."*
   *
   * ★ ONE CAUSE, TWO SYMPTOMS, AND NOTHING WAS EVER DELETED. No code clears the
   *   caption. The whisper tier is ONE LINE tall, and `_paintCaption()` pinned
   *   `scrollTop` to the bottom, so the box could only ever show the NEWEST line —
   *   everything before it sat above the fold. That is exactly right while she is
   *   speaking (live subtitles) and exactly wrong the moment she stops.
   *
   * ── WHY 124px (6 LINES) — MEASURED, NOT GUESSED, AND MEASURED TWICE ──────────
   * `V28` REFUTED MY FIRST TWO ESTIMATES ON CONSECUTIVE RUNS, and both refutations
   * were worth having:
   *
   *   attempt 1 — reuse the base 82px (4 lines), assuming a 37-word reply ≈ 3 lines.
   *               MEASURED: `hiddenPx=40` — a third of her answer still hidden.
   *   attempt 2 — 102px (5 lines), assuming the reply needed 5.
   *               MEASURED: `hiddenPx=20` — still one line short.
   *   attempt 3 — 124px (6 lines). MEASURED `hiddenPx=0`.        ← this one
   *
   * ★ THE ARITHMETIC I KEPT GETTING WRONG, AND THE FIX FOR IT. The caption BOX is
   * 408px wide, but the TEXT COLUMN is only **320px** — minus this rule's 6px left
   * inset and the **82px reserved for her portrait**. At 14px that is **~6 words per
   * line**, not the ~14 the box width suggests, so a 37-word reply is **6 lines
   * (122px)** — twice my estimate. The lesson: measure the reply INSIDE the caption,
   * never infer lines from word count and box width.
   *     unclamped / what the reply NEEDS   122.0px = 6.0 lines
   *     attempt 1 (base cap)                82.0px = 4.0 lines → hiddenPx=40
   *     attempt 2                          102.0px = 5.0 lines → hiddenPx=20
   *     attempt 3 (this)                   124.0px = 6.0 lines → hiddenPx=0   ✓
   *
   * ⚠ SO DO NOT TRIM THIS BACK TOWARD 82px. 82px is the cap for the WHISPER tier, a
   * different box with different content; it was never the canonical size for the
   * settled reading state. Any change here must be re-validated by running `V28`,
   * which drives the visitor's real reply and asserts `hiddenPx <= 1` — it will catch
   * a too-small cap immediately (it did, twice).
   *
   * ⚠ 124px IS STILL A CEILING, NOT INFINITE. A reply longer than 6 lines at this
   * width still caps and scrolls. Showing unbounded text would remove the bounded bar
   * entirely and bring back the "the caption window is too high" fault an earlier
   * session fixed — so the ceiling stays, and the reported reply now fits WHOLE.
   *
   * ⚠ THIS MAKES THE SETTLED BAR TALLER THAN THE EXPANDED TIER'S 82px, WHICH IS
   * CORRECT AND NOT A BUG. The two are different states with different jobs: the
   * expanded tier is what she wears WHILE TALKING (a bounded subtitle frame), the
   * settled tier is what she wears WHILE BEING RE-READ (a paragraph). They are never
   * on screen at the same time, and `_clearSettle()` returns the bar to the thin
   * bullet on the next turn regardless.
   *
   * ⚠ IT IS CLEARED ON THE NEXT TURN (`_clearSettle()`), so the thin bullet returns
   *   for the next reply. A bar that stayed tall would be the old complaint again.
   *
   * ⚠ `V28` (qa/orb_visual_test.py) drives a real 37-word reply and asserts BOTH
   *   halves: settled shows all of it with `scrollTop === 0`, and mid-speech is STILL
   *   the one-line whisper tier. A settle that fired during speech would destroy the
   *   read-along; one that did not reset the scroll would leave the bug in place. */
  /* ── ★★ THE DESKTOP NON-SPEAKING CAP IS NOW VIEWPORT-AWARE (2026-10-01) ─────
   *
   * ── WHY: THE VISITOR SAW CLIPPING ON PC AND NOT ON PHONE ─────────────────────
   *   "the pc format is cutting a lot of text at the top ... make the caption
   *    window adoptable by size to fit whatever text is in it"
   *
   * MEASURED (`qa/orb_caption_viewport_live.py`, 5 viewports × 2 states):
   *
   *                   settle fired  |  settle NOT fired
   *   PC 1920x1080     CLIP 0        |  CLIP 80   <- every desktop failed
   *   PC 1440x900      CLIP 0        |  CLIP 80
   *   PC 1280x720      CLIP 0        |  CLIP 80
   *   iPhone 390       CLIP 0        |  CLIP 0    <- mobile NEVER failed
   *   Android 412      CLIP 0        |  CLIP 0
   *
   * ★ THE ASYMMETRY WAS THE BUG. Mobile's cap is `40vh` at ALL times, so it cannot
   *   be affected by a missed callback. Desktop's cap was a flat 82px until
   *   `_settleCaption()` raised it — and that runs only from the audio-completion
   *   callback, so a barge-in, a mute or an interrupted turn left the box at the
   *   SPEAKING height and hid most of her answer.
   *
   * ⚠⚠ THE FIRST VERSION OF THIS RULE WAS WRONG, AND THE SUITE CAUGHT IT — TWICE.
   *   Attempt 1 keyed on `:not([data-ct-orb-settled="1"])`, which is TRUE WHILE SHE
   *   IS SPEAKING, so the wide cap applied to the read-along and V18, V28 and V29
   *   all went RED. **"Not settled" is not the same claim as "not speaking".**
   *   Attempt 2 excluded SPEAKING/GREETING by name, which fixed V29 but STILL broke
   *   V18 (`a_whisper_caps_at_one_line`): the WHISPER tier is a SHORT reply capped
   *   at one line, and it is neither speaking nor settled — so a blanket wide cap
   *   swallowed it too. **A tier is not a state.**
   *
   * ★★ SO THE RULE IS NOW SCOPED TO THE SETTLED TIER AND NOTHING ELSE. That is the
   *   only state where "her words are a passage to re-read" is true; the whisper
   *   tier (21px, one line) and the base speaking tier (82px, rolling window) keep
   *   their own caps untouched, exactly as the earlier sessions designed them.
   *
   * ⚠ THE REAL FIX IS IN JS, NOT HERE. `_fitCaptionToText()` runs from
   *   `_paintCaption()` and `_settleCaption()`, so the box sizes to the text on the
   *   real path. THIS rule is only the safety net for a reply whose settle was
   *   missed — and the JS is what raises the cap for that reply, because the
   *   `data-ct-orb-settled` attribute is not set when the callback never fires.
   *
   * ⚠ THE LIMIT IS STILL A LIMIT. `min(340px, 42vh)` keeps the input row and her
   *   portrait on screen, which is the complaint an earlier session fixed. */
  .ct-orb-panel[data-ct-orb-settled="1"] .ct-orb-caption {
    /* ★★ ADAPTIVE SINCE 2026-09-30 — THE CEILING IS NOW A CEILING, NOT THE SIZE.
     *
     * ── THE REPORTED FAULT (the fourth bug out of this caption stack) ──────────
     *   "i can see from this down ... the part, 'Robin Hood is one of English
     *    folklore's most beloved outlaws - a master archer who stole' is missing,
     *    or cut off from the top, looks like the caption box is not expanding for
     *    longer details."
     *
     * ★ HE WAS RIGHT, AND THE CAUSE WAS THE HARD 124px BELOW. It had been measured
     *   against ONE reply — a 37-word one — where it gave `hiddenPx=0`. MEASURED
     *   with the reply that exposed the fault (`qa/orb_settled_cap_live.py`):
     *
     *       37 words -> 101px needed -> cap 124px -> hiddenPx=0     (fits)
     *       69 words -> 162px needed -> cap 124px -> hiddenPx=38    HIDDEN
     *      117 words -> 304px needed -> cap 124px -> hiddenPx=180   HIDDEN
     *
     * ★ AND THE BAR WAS ALREADY 208px TALL. 84px of the panel sat EMPTY while the
     *   visitor could not read the top of her own answer. The cap was the ONLY
     *   thing in the way — so the fix is not "a bigger number", it is "ask the text".
     *
     * ⚠ WHY 124px DID NOT SIMPLY BECOME 340px. A fixed value must be re-guessed for
     *   every reply length, and this project has now got that guess wrong THREE times
     *   (82px -> 102px -> 124px were all measured too small, each one against a
     *   shorter reply than the next). `_settleCaption()` instead sizes the box to
     *   the reply's own `scrollHeight`, capped by `--ct-orb-settled-limit`.
     *
     * ⚠ THE LIMIT STAYS, AND IT IS THE POINT. Unbounded text would bring back the
     *   earlier complaint — *"the size of the caption window is too high"* — so a
     *   NORMAL reply now fits WHOLE and only a genuine essay caps and scrolls.
     *   The JS keeps its own 42vh ceiling for the same reason; this value is the
     *   pre-JS fallback (and the cap when a harness has no layout engine).
     *
     * ⚠ THE OTHER TIERS ARE UNTOUCHED. The whisper (21px) and base (82px) rules are
     *   what make the read-along a rolling subtitle window while she SPEAKS. Only the
     *   settled reading state is adaptive, and `_clearSettle()` removes the per-reply
     *   override on the next turn so the thin bullet always returns. */
    --ct-orb-settled-limit: min(340px, 42vh);
    --ct-orb-caption-max: var(--ct-orb-settled-max, var(--ct-orb-settled-limit));
  }

  /* ── ★ HER WORDS KEEP OUT OF HER OWN COLUMN (2026-09-22, v2) ──────────────
   *
   * WHY THIS IS LOAD-BEARING, NOT DECORATION. v1 of the dock held the layout
   * clear of her with a 67px `padding-bottom` band across the whole bar — the
   * dead space the visitor reported as "the size of the caption window is too
   * high". Removing it (v2) is only safe if no TEXT can reach the bar's bottom
   * right region, because that region is now where SHE lives.
   *
   * ⚠ IT MUST GO ON THE TEXT ELEMENTS, NOT ON `.ct-orb-head` (measured, twice).
   * Two failed attempts taught this, and both failures were invisible in a
   * screenshot:
   *
   *   1. Putting it on `.ct-orb-form` alone left her CAPTION running under her
   *      own portrait — the input was fixed and the text was not.
   *   2. Putting it on `.ct-orb-head` (the flex parent of both) changed nothing.
   *      `.ct-orb-head` is `display: flex`, so its padding shrinks its CONTENT
   *      box — but `.ct-orb-caption` and `.ct-orb-state` are flex children that
   *      FILL that content box and then add their OWN `padding: 0 12px` on top.
   *      The element measured narrower and the text did not move at all.
   *
   * ⚠⚠ AND IT MUST OUT-SPECIFY THE BASE RULES, HENCE THE `#ct-orb-panel` PREFIX.
   * The base `.ct-orb-caption` and `.ct-orb-state` declare `padding: 0 12px` and
   * sit LATER in this file, so a bare `.ct-orb-caption { padding-right: … }`
   * loses the cascade and the value silently computes back to 12px — exactly what
   * happened on the first attempts. A single ID prefix makes the intent explicit
   * and cannot be overturned by a later single-class rule. Do not "simplify" it.
   *
   * ★ THE CLEARANCE IS THE CAPPED DIAMETER, NOT THE BAR HEIGHT. This is the
   * second half of the feedback fix (see `--ct-orb-max` on the orb). Using
   * `--ct-orb-height` here meant a longer reply → taller bar → wider clearance →
   * narrower caption → taller wrap → wider clearance, and the probe caught the
   * two chasing each other with her left edge at 1307.1 against text ending at
   * 1328. Reading the SAME cap the orb reads ends the loop: the space reserved
   * and the space she occupies are now literally the same expression, so they
   * cannot disagree. */
  #ct-orb-panel .ct-orb-caption,
  #ct-orb-panel .ct-orb-state,
  #ct-orb-panel .ct-orb-form {
    --ct-orb-max: 68px;
    padding-right: calc(12px
                        + clamp(var(--ct-orb-size),
                                var(--ct-orb-height, var(--ct-orb-size)),
                                var(--ct-orb-max))
                        + 14px);
  }
  /* The chips are an ANSWER surface, not a text flow: they sit above the input
     and never reach her column, so they keep the full width. */
}

/* ── ★ THE CLEAR ZONE ENDS WHERE THE PHONE STAGE BEGINS ────────────────────
 * On a phone the panel is a full-screen stage and the orb is a centred hero
 * ABOVE the text — her column overlaps nothing, so reserving text width would
 * waste a thumb's worth of the screen for no reason. The two languages must
 * agree about the breakpoint, so this is written against the same 480px the
 * stage and `_isPhoneWidth()` use, and `V1 the_desktop_dock_does_not_leak_onto_
 * the_stage` measures it on a real 390px viewport. */
@media (max-width: 480px) {
  /* ⚠ These must be as SPECIFIC as the desktop dock rule above (which is
     `#ct-orb-panel .ct-orb-caption` etc.), or the dock's clearance would leak
     onto the phone stage and squeeze her text for an orb that is not beside it
     there. Same selectors, same prefixes. */
  #ct-orb-panel .ct-orb-form { padding-right: 0; }
  /* The caption and state keep their own quiet text inset on the stage, where her
     column overlaps nothing at all. */
  #ct-orb-panel .ct-orb-caption,
  #ct-orb-panel .ct-orb-state { padding-right: 12px; }
  /* ★ AND THE FOLD CONTROL LOSES ITS DESKTOP MARGIN TOO (v7). On the stage the orb
     is a centred hero ABOVE the panel and the fold sits in the stage HEADER, not in
     the input row, so there is no column to clear — leaving the desktop row margin
     in place would shove it off its own baseline for no reason. `V9`/`V13` cover the
     stage and this keeps the two surfaces honest about which one the margin belongs
     to. ⚠ The `::after` 44px target was declared OUTSIDE every media block on purpose
     → and is now REMOVED WITH THE BUTTON (2026-10-22). */
  /* ★ `#ct-orb-panel .ct-orb-min { margin: 0; }` REMOVED (2026-10-22) — the stage no
     longer carries a fold control, so there is no margin to reset. */
}

/* ── the mobile stage header (hidden on desktop) ─────────────────────────
 * On a phone the panel becomes FULL SCREEN (see the ≤480px block) and this is
 * the strip across its top: a grip that says "this sheet can go away", and the
 * − that does it. Both are inert above 480px, where the panel is a bar beside
 * the orb and `_collapse()` is unnecessary (the bar is already out of the way).
 */
.ct-orb-mobile-bar { display: none; }

/* ── her text: the read-along line + the status row ─────────────────────── */

.ct-orb-head {
  display: flex;
  flex-direction: column;
  gap: 5px;
  min-width: 0;
}

/* ── ★ THE HEAD'S TOP ROW: HER WORDS AND THE FOLD CONTROL (v6b) ────────────
 *
 * Adding the fold control to the head needed a row, not a rewrite. The head is a
 * column of [caption, status]; the row groups the caption with the control so the
 * control can sit at the TOP-RIGHT while the status stays full-width underneath.
 *
 * ⚠ `align-items: flex-start` IS LOAD-BEARING. With the default `stretch` the
 * control would be stretched to the full height of a multi-line reply, so a
 * four-line answer would produce a 60px-tall ellipsis of a button. It is pinned to
 * the top edge instead — which is also what "top-right corner" means.
 *
 * ⚠ `min-width: 0` on the caption is what lets a long word WRAP instead of pushing
 * the control out of the bar. A flex item's default `min-width: auto` refuses to
 * shrink below its content, so without it a long unbroken string would shove the
 * button past the panel's edge. */
.ct-orb-headrow {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  min-width: 0;
  /* ★ THE FOLD CONTROL'S KEEP-OUT ZONE, PUBLISHED AS A VARIABLE (v6b).
   *
   * This is the SAME expression the caption's `padding-right` uses (12px + the orb's
   * height capped at `--ct-orb-max` + 14px), because the two must agree: the caption
   * stops where her column begins, and the fold control must stop in the same place
   * or it lands on her portrait.
   *
   * ⚠ IT LIVES ON THE HEADROW, WHICH IS WHY IT READS `--ct-orb-max` WITH A FALLBACK
   * RATHER THAN ASSUMING IT EXISTS HERE. `--ct-orb-max` is published on the ORB and
   * read by the caption via a `#ct-orb-panel`-scoped rule; the headrow is a different
   * subtree, so the fallback (`68px`, the documented cap) is what actually resolves on
   * the desktop bar. That is deliberate rather than sloppy: the value is a CAP, and a
   * cap that silently becomes `0` when a variable is missing would collapse the
   * control onto her portrait — the exact failure this margin exists to prevent.
   *
   * ⚠ ON THE PHONE STAGE THIS CLEARING DOES NOT APPLY — the orb is a centred hero above
   * the panel, so there is no column to keep out of. The ≤480px block resets it. */
  --ct-orb-clear: calc(12px
                      + clamp(var(--ct-orb-size),
                              var(--ct-orb-height, var(--ct-orb-size)),
                              var(--ct-orb-max, 68px))
                      + 14px);
}
.ct-orb-headrow .ct-orb-caption { flex: 1 1 auto; min-width: 0; }

/* ── ★ THE FOLD CONTROL'S TOUCH TARGET — SHARED BY BOTH SURFACES (v6b) ──────
 *
 * ⚠ THIS RULE MUST LIVE **OUTSIDE** THE DESKTOP MEDIA BLOCK, AND THAT IS A BUG FIX.
 * The first version put it inside `@media (min-width: 481px)` alongside the desktop
 * styling, so on a 390px phone the pseudo measured `content: 'none'` — i.e. the
 * button had NO hit area at all beyond its own 26px chevron, on the one platform
 * where the 44px minimum actually matters. Only mobile QA could see it.
 *
 * ★ THE TARGET IS A SEPARATE CONCERN FROM THE POSITION. Where the control sits is
 * a per-surface decision (the head on desktop, the stage header on a phone); how
 * big a thumb target it offers is not. Keeping them in the same media query coupled
 * two unrelated things and silently dropped one of them.
 *
 * ⚠ CENTRED AND FIXED AT 44px, NOT INSET BY A MARGIN. An `inset: -9px` form is
 * correct only while the button is 26px; the phone stage overrides it to 40px, so
 * `-9px` would produce a non-square target. Sizing it absolutely and centring it
 * makes the target exactly 44px whatever the painted button measures. */
/* ── ★ THE FOLD CONTROL — BASE (v7) ──────────────────────────────────────────
 *
 * ⚠ IT MUST BE DECLARED **HERE**, OUTSIDE EVERY MEDIA BLOCK, FOR THE SAME REASON
 * THE TOUCH TARGET IS. In v6b the control's only desktop appearance came from the
 * `min-width: 481px` block, so on a phone the button had NO base styling at all and
 * relied entirely on the ≤480px stage block — which meant the two surfaces could
 * disagree about its size, and did (`V9` caught a non-square 26×44 target).
 *
 * ★ WHAT BELONGS HERE VS. IN A MEDIA BLOCK IS NOW EXPLICIT:
 *   • HERE      — the things that are true of the control EVERYWHERE: that it is a
 *                 flex item, that it is round, that it cannot be dragged, and that
 *                 it centres its own glyph. These are not decisions.
 *   • DESKTOP   — where it sits in the input row and how much room it takes.
 *   • STAGE     — that it grows to a 44px painted disc in the sheet header.
 *
 * ⚠ THE BUTTON IS ALWAYS VISIBLE (v7). It has no `opacity` at any breakpoint, so a
 * keyboard visitor can always reach it with Tab and a screen reader can always
 * announce it. The whole panel is `hidden` at rest, which is what keeps it off the
 * page before the visitor taps — not an opacity trick.
 *
 * ⚠ THE PAINTED CIRCLE IS 26px ON DESKTOP AND 44px ON THE STAGE; THE TOUCH TARGET IS
 * 44px ON BOTH. The `::after` below supplies the difference. A 44px painted circle in
 * a 44px input row would force the text field down to nothing.
 */
/* ★★★ `.ct-orb-min` — THE BASE RULE WAS DELETED WITH THE BUTTON (2026-10-22) ★★★
 *
 * REMOVED HERE: the 26px painted circle, its hover/focus-visible state, and the
 * `::after` 44px touch target. All three existed only for the `−`.
 *
 * ── WHY THE WHOLE BLOCK GOES, AND NOT JUST THE MARKUP ──────────────────────
 * A deleted control with surviving CSS is not deleted — it is *invisible but
 * styleable*. The next person to add "a small round control to the row" inherits
 * a 26px circle with a 44px hit area that nobody can find in the JS, and the
 * `.clinerules` line "the − was deliberately removed" then looks wrong. Dead CSS
 * for a deleted control is how a removal gets quietly undone.
 *
 * ⚠ IF A FOLD CONTROL IS WANTED AGAIN: do not restore this block. EEH deleted the
 * button on purpose (`S-ORB-NO-MINUS`, 2026-10-10) and the visitor's words are on
 * record — *"just to click the orb to reduce and keep quite."* The fold lives on
 * the orb tap (`_ctOrbToggle()`), and `qa/orb_greeting_on_click_test.py`
 * W10/W11 fail if a `−` returns.
 */

/* ★ NO DEAD SPACE (the "unbalanced" half of the report). When she has said
   nothing AND is not mid-turn, the whole head disappears and the panel is a
   single slim input bar. `:empty` covers both children, and because both are
   built empty and only ever filled by JS, this is exact rather than heuristic. */
.ct-orb-head:empty { display: none; }

/* The status line: four short warm strings ("I'm listening…"). Deliberately
   quiet — 12px, 62% white, indented by 12px so it aligns with the INPUT's text
   rather than the bar's edge. It is a whisper about the microphone; her actual
   words get the size and the contrast below. */
.ct-orb-state {
  font-size: 12px;
  line-height: 1.3;
  color: rgba(255, 255, 255, 0.62);
  padding: 0 12px;
  letter-spacing: 0.01em;
}

.ct-orb-state:empty { display: none; }

/* ── the input row ─────────────────────────────────────────────────────── */

.ct-orb-row {
  display: flex;
  align-items: center;
  gap: 2px;
  min-width: 0;
}

/* Show exactly one glyph depending on the state. */
.ct-orb-mute .ct-orb-mute-off { display: none; }
.ct-orb-mute.ct-orb-muted .ct-orb-mute-on  { display: none; }
.ct-orb-mute.ct-orb-muted .ct-orb-mute-off { display: inline; }

/* ★ MUTE — ALWAYS VISIBLE NOW (v7), AND THAT REVERSES v6 ON PURPOSE.
 *
 * The visitor's report, and it is precise:
 *
 *     "speaker icon is not visible but only when scrolled upon, should be visible
 *      all the time"
 *
 * ⚠ THE OLD BEHAVIOUR WAS DELIBERATE, NOT A BUG, and the comment it replaced said
 * so: it shipped at `opacity: 0` and appeared on hover / focus / once engaged,
 * because an earlier visitor had reported a permanently visible speaker as
 * "on top speaker icon to mute … a large part of why the panel felt unbalanced".
 * Two visitors, two opposite verdicts, which is exactly why this is written down
 * rather than silently flipped.
 *
 * ★ WHAT MAKES THE NEW VERDICT RIGHT: the panel is now much smaller than when the
 * original complaint was made. The unbalanced panel was the 157px slab with a
 * status row, a caption and a wide input; today it is a 66px strip, and the mute
 * has moved from floating ABOVE the caption into the input row where it shares a
 * baseline with the field. The thing that made it feel like chrome is gone, so
 * hiding it now costs more than it saves — a control the visitor cannot see is one
 * they cannot use, and the two moments it is most wanted (she is loud in a shared
 * room; she will not stop talking) are both moments when hovering is the LAST thing
 * on their mind.
 *
 * ⚠ SO: NO `opacity: 0`, AND THE THREE REVEAL RULES ARE DELETED. `OB-29` (mute
 * exists, silences audio, remembers the choice) and `OB-30` (a muted visitor still
 * READS every reply in the caption) are untouched — this changes only WHEN the
 * control is painted, never what it does. The "silence her voice, keep her words"
 * accessibility need is intact and now easier to reach.
 *
 * ⚠ KEEPING `opacity` AS THE MECHANISM, NOT `display` — the rules below that hide
 * one GLYPH or the other depend on the button box existing at all times.
 */
.ct-orb-mute {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: transparent;
  color: rgba(255, 255, 255, 0.72);
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
  touch-action: manipulation;
  /* ★ NO `opacity: 0` (v7). The reveal rules that used to follow are DELETED, not
     disabled — see the block comment above for why the visitor's verdict reversed
     the original one. */
  transition: background 0.15s ease, color 0.15s ease;
}

.ct-orb-mute:hover,
.ct-orb-mute:focus-visible {
  background: rgba(255, 255, 255, 0.10);
  color: #fff;
  outline: none;
}

/* Show exactly one glyph depending on the state. */
.ct-orb-mute .ct-orb-mute-off { display: none; }
.ct-orb-mute.ct-orb-muted .ct-orb-mute-on  { display: none; }
.ct-orb-mute.ct-orb-muted .ct-orb-mute-off { display: inline; }

/* A muted control must stay VISIBLE even while she is silent, or the visitor has
   no way to discover how to turn her voice back on. This is the one case where
   the control outranks the "only when relevant" rule — an engaged state the
   visitor cannot undo is worse than a visible button. */
.ct-orb-panel .ct-orb-mute.ct-orb-muted {
  opacity: 1;
  color: var(--ct-orb-gold-lit);
}

/* The caption states what is happening, in words, at all times. On a touch
   device this is the ONLY textual signal (the hover-pill never shows).

   ★ THIS IS THE READ-ALONG LINE (2026-09-20: it now holds ONLY her words).
   The four status strings ("I'm listening…") moved to `.ct-orb-state` above, so
   anything painted here is her actual reply — which means the box may size itself
   to its text instead of reserving 80px for four words.

   ── WHAT IS PRESERVED FROM THE 2026-09-16 FIX ─────────────────────────────
   The trailing window + the bottom scroll pin are UNCHANGED and both live in
   `_showWindow()` / `_paintCaption()`. They solve a real, reported bug: growing
   prefixes plus `overflow: hidden` clipped the NEWEST words (exactly the ones she
   was saying). JS still renders the tail and pins `scrollTop = scrollHeight`, so
   the visible lines are always the newest ones — subtitle behaviour.

   ★ WHAT CHANGED, AND WHY IT IS SAFE. The old rule was a hard
   `height: 80px; min-height: 80px; max-height: 80px`. `min-height: 80px` is what
   forced the dead space; removing it and capping with `--ct-orb-caption-max`
   (which `_captionLines()` reads as 4 lines, and `_showWindow()` uses to size its
   window) gives one strict improvement: a reply that needs at most 4 lines — the
   overwhelming majority — LOOKS EXACTLY AS IT DOES TODAY, pixel for pixel, with
   the same bottom pinning and the same "newest word is visible" guarantee. A
   longer reply clips at 4 lines instead of 3, which is strictly MORE text, and
   the newest words are still the ones shown.

   `_captionLines()` reads the computed style, so the JS follows this variable
   automatically — there is no second place to update. */
.ct-orb-caption {
  /* ★ THE CAPACITY, as one variable: 4 lines at line-height 1.45 with 14px text
     (4 × 20.3 = 81.2px). `overflow: hidden` clips at this point and JS pins the
     scroll, so it behaves as a "show the newest 4 lines" cap rather than a
     growing box. Measured from the same numbers `_captionLines()` derives. */
  --ct-orb-caption-max: var(--ct-orb-settled-max, 82px);
  /* ★ AN ASYMMETRIC INSET, BECAUSE THE BAR IS ASYMMETRIC (2026-09-25).
   *
   * The visitor: *"cutting too much on the left ... make it more balanced, short and
   * logical."* MEASURED on the open bar: the text started **20px** inside the bar
   * (7px panel padding + 12px here) on a bar only ~50px of content tall — so on a
   * squat bullet a 20px lead-in read as a slab of the shape missing from the left.
   * The horizontal inset has to be PROPORTIONAL TO THE HEIGHT, not a fixed number
   * inherited from when the bar was 150px tall.
   *
   * ⚠ SO THE TWO SIDES NOW HAVE DIFFERENT JOBS, AND THAT IS THE POINT:
   *   • LEFT  (`6px`) is the TEXT'S LEAD-IN. With the panel's own padding it lands at
   *     ~11px, which sits comfortably inside a 10px corner curve instead of
   *     overrunning it.
   *   • RIGHT (`12px`) IS NOT A MARGIN — it is the start of *her column*. It is
   *     deliberately left alone because the caption's `padding-right` is the first
   *     term of the calculated clearance that keeps her words off her portrait
   *     (see `#ct-orb-panel .ct-orb-caption` below, and the wrap/grow feedback loop
   *     it documents). Shortening it would put text under the orb.
   *
   * ⚠ NO LOCK ASSERTS THIS VALUE (checked: neither suite greps the caption's padding),
   * so this is a free number to tune — but the RIGHT side must stay 12px, and the
   * `padding-right: calc(...)` rule that follows is what actually reserves her column. */
  padding: 0 12px 0 6px;
  /* ★ THEMED (v8) — her words read from the palette so a light theme (Ivory)
     can use dark text. Fallback is the shipped white, so an absent theme is unchanged. */
  color: var(--ct-orb-text, #fff);
  line-height: 1.45;
  /* Her read-along line is the loudest text in the bar: full white at 14px while
     the status whisper above it is 12px at 62%. That contrast IS the hierarchy. */
  font-size: 14px;
  /* ★ AUTO HEIGHT (was a fixed 80px — the source of the dead space). The box is
     exactly as tall as her words up to the cap; the bottom pin keeps the newest
     line in view; and `overflow: hidden` means no scrollbar can ever appear. */
  max-height: var(--ct-orb-caption-max);
  overflow: hidden;
  /* Long unbroken strings (URLs, codes) must wrap, never force a wider box. */
  overflow-wrap: anywhere;
  word-break: break-word;
}

.ct-orb-caption:empty { display: none; }

/* The type-instead-of-speak row. */
.ct-orb-form {
  display: flex;
  gap: 6px;
  align-items: center;
}

/* ══ ★★ THE EMPTY WHITE BOX — THE THEME WAS PAINTING THE INPUT (2026-09-25) ══
 *
 * ── THE REPORT ────────────────────────────────────────────────────────────────
 *   "the empty white box is not too big ... with saying something, like text or talk
 *    etc. is no longer there"
 *
 * ── ★ IT WAS NEVER THE CAPTION. MEASURED (real Chromium, 1440x900, local, after
 *    her greeting), on the SAME frame: ────────────────────────────────────────
 *     .ct-orb-caption   rect 0,0        display: none     ← the caption is EMPTY
 *     .ct-orb-panel     rect 420 x 108.4
 *     .ct-orb-input     rect 198 x 47.8
 *     .ct-orb-input   backgroundColor rgb(255, 255, 255)  ← ★ THE WHITE BOX
 *                     color           rgb(39, 38, 38)
 *                     placeholder     "Text or talk…"     ← present, painted WHITE
 *                     colorScheme     dark                ← inherited correctly
 *   So the tall empty rectangle the visitor saw WAS the input field, and the missing
 *   "text or talk" was its `::placeholder` rendering white-on-white.
 *
 * ── WHY OUR WASH LOST, AND WHY IT WAS NOT `color-scheme` ──────────────────────
 * The panel's `color-scheme: dark` is correct and DOES inherit (the probe reads
 * `colorScheme: dark`). But the field was still white because **the active theme
 * (Neve) wins the cascade on the element itself**:
 *
 *     theme  : form input:read-write, form select, …  { background: var(--formfieldbgcolor); color: … }
 *              ^ specificity (0,1,2)  ← the winning rule
 *     ours   : .ct-orb-input                          { background: rgba(255,255,255,0.06); … }
 *              ^ specificity (0,1,0)  ← applied, then overridden
 *
 * Both rules matched (verified by enumerating `document.styleSheets`); the theme's
 * simply has more weight. `--formfieldbgcolor` is the site's WHITE form colour, so
 * the orb inherited a white field from a theme that knows nothing about it.
 *
 * ★ THE FIX IS SCOPE, NOT AN ESCALATION. Prefixing with `#ct-orb-panel` gives
 * (1,1,0) — above any plain-form theme rule and still BELOW a theme that resorts to
 * `!important`. This is the SAME pattern the dock already uses for its own
 * overrides (see `#ct-orb-panel .ct-orb-caption`), so there is one house style for
 * "the orb must win against the page".
 *
 * ⚠ DO NOT SOLVE THIS WITH `!important`. It would work and it would be wrong: a
 * child theme that legitimately styles `#ct-orb-panel` could then never win, and
 * this file already documents the one place where `!important` is genuinely
 * required (the `[hidden]` rule, where no selector can express the intent). */

#ct-orb-panel .ct-orb-input,
#ct-orb-panel .ct-orb-input:read-write {
  flex: 1 1 auto;
  min-width: 0;
  /* ★ THE SLIM FIELD (2026-09-25). The visitor's other word was "skinnier": the
     resting bar should read as a single slim stripe with a hint in it, not as a
     container holding an empty rectangle. 10px → 6px of block padding (via 8px) takes
     the field from 47.8px to 48px-floored, and the resting bar from 108.4px to ~54px.
     14px text keeps its legibility.
     ⚠ THE HIT TARGET IS NOT THIS PADDING. The field is the row's flexible half, so
     trimming it does not shrink what a finger can tap, and the row's own
     `align-items: center` keeps every control on one baseline.
     ⚠⚠ THE BLOCK PADDING IS LOAD-BEARING **AND** DELICATE — DO NOT RAISE IT BACK
     WITHOUT READING THIS. 10px → 8px saved 4px, and V15 caught the resting bar
     dropping to a 48px CONTENT box against the orb's 56px: the resting tier is
     `3px padding + row + 2px border`, so the row is the ONLY thing holding the bar
     open. The `min-height` below is what turns that into a guarantee rather than
     arithmetic — and that floor, not this padding, is what now makes the bar
     deliberately shorter than the orb. */
  padding: 5px 12px;
  /* ★ THE ROW MAY NEVER BE SHORTER THAN THE PORTRAIT (2026-09-25).
     Measured failure without a floor: `orbH=56.1 barContentH=48.0` — she would be
     clipped by her own bar at rest.
     ⚠⚠ THE FLOOR IS NOW 48px, NOT 56px, AND THAT IS DELIBERATE (2026-09-25).
     The visitor asked for the bar to sit "skinnier than the orb", which means the
     bar must be able to go BELOW her 56px diameter — and this row is what holds the
     bar open at rest (the tier is `3px padding + this row + 2px border`). A 56px
     floor made "shorter than the orb" arithmetically impossible.
     MEASURED, resting tier: 56px floor → bar 62px; 48px floor → bar **~54px**, i.e.
     2px under her diameter, so she now reads as PROUD of her own strip — the look the
     visitor asked for — while the row keeps a 48px hit target, comfortably above the
     44px accessible minimum.
     ⚠ AND HER RING IS STILL CLEARED. The earlier note here warned that a content box
     of exactly 56px left the orb's painted height at **58.35px** against `V15`'s ±2
     because the orb carries a ~2.3px ring OUTSIDE its box — so the bar needed slack
     ABOVE the portrait. That reasoning still holds and is now satisfied from the other
     direction: the orb is no longer trying to fit inside the strip at all. It is
     clamped at `--ct-orb-size` (56px) by the dock's own `clamp()`, which is the LOWER
     bound — so a 54px bar gives a 56px orb that deliberately overhangs by ~1px top and
     bottom. `V15` was re-derived to assert exactly that (`overhang`, not `equality`).
     ⚠ DO NOT RAISE THIS BACK TO 56px without reading the paragraph above: the
     "skinnier than the orb" property depends on this number being below 56. */
  min-height: 44px;
  box-sizing: border-box;
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 12px;
  /* ★ THE WELL, NOT A SECOND BOX. Was 0.06, which is correct ONLY when nothing is
     painted underneath. Reaching the themed translucent look needs the wash to
     carry a little more body so a light page cannot read through it as "white". */
  background: rgba(255, 255, 255, 0.10);
  /* ★ THEMED (v8) — light themes need dark text; the `var()` is what keeps Ivory
     (near-black `--ct-orb-text`) readable without a second palette rule.
     ⚠ BOTH `color` AND THE FILL ARE SET: WebKit/Blink consult the FILL for the
     caret, for autofilled text and (on some builds) for the placeholder, so `color`
     alone leaves the UA's own near-black pairing in place. */
  color: var(--ct-orb-text, #fff);
  -webkit-text-fill-color: var(--ct-orb-text, #fff);
  font-size: 14px;
  line-height: 1.3;
  outline: none;
  -webkit-appearance: none;
  appearance: none;
  /* ★ THE CARET, CHOSEN FOR THE FIELD RATHER THAN INHERITED FROM THE PAGE. */
  caret-color: var(--ct-orb-gold-lit, #e8c07a);
}

/* ★ THE PLACEHOLDER MUST BE READABLE ON BOTH FIELD COLOURS (2026-09-25).
 *
 * It used to be the flat literal `rgba(255, 255, 255, 0.55)`. That is correct contrast
 * on the themed translucent wash — and it is INVISIBLE on the white UA field the
 * missing `color-scheme` produced. That is the reported "saying something, like text
 * or talk etc. is no longer there": the hint was painted white on white.
 *
 * ⚠ `-webkit-text-fill-color` IS REPEATED HERE ON PURPOSE. `::placeholder` does not
 * inherit the field's fill on every engine, so the property must be restated or the
 * placeholder keeps the UA's own near-black pairing.
 *
 * ⚠ THE FALLBACK IS DERIVED FROM `--ct-orb-text`, NOT FROM WHITE. The theme's own
 * text colour at 58% is legible on BOTH a dark and a light field, which a white
 * literal is not — and it keeps Ivory (near-black text) correct without a second
 * palette-specific rule. `color-mix` degrades to the white literal where unsupported,
 * which is exactly the case that was already correct. */
.ct-orb-input::placeholder {
  color: var(--ct-orb-dim,
               color-mix(in srgb, var(--ct-orb-text, #fff) 58%, transparent));
  -webkit-text-fill-color: var(--ct-orb-dim,
               color-mix(in srgb, var(--ct-orb-text, #fff) 58%, transparent));
  opacity: 1;   /* Firefox applies its own 0.54 alpha on top of ours */
}

/* ⚠ THE SCOPED FORM, FOR THE SAME REASON THE FIELD ITSELF HAS ONE (2026-09-25).
   A `::placeholder` rule is a SEPARATE cascade entry from the field: raising the
   field's specificity does NOT raise this one, so a theme selector of comparable
   weight would still paint the hint in its own colour. The theme that caused the
   white field sets a `color` alongside its `background`, which is exactly how the
   hint went white-on-white. */
#ct-orb-panel .ct-orb-input::placeholder {
  color: var(--ct-orb-dim,
               color-mix(in srgb, var(--ct-orb-text, #fff) 58%, transparent));
  -webkit-text-fill-color: var(--ct-orb-dim,
               color-mix(in srgb, var(--ct-orb-text, #fff) 58%, transparent));
  opacity: 1;
}

/* ★ AUTOFILL DEFENCE (2026-09-25) — the UA's remembered-answer paint is an OPAQUE
   near-white wash that would put this exact white box straight back the first time a
   browser offers to remember what the visitor typed. The accepted remedy is an inset
   shadow that fills the field, plus a huge transition so the UA's own colour never
   gets a chance to paint. ⚠ The long duration is intentional, not a typo. */
.ct-orb-input:-webkit-autofill,
.ct-orb-input:-webkit-autofill:hover,
.ct-orb-input:-webkit-autofill:focus {
  -webkit-text-fill-color: var(--ct-orb-text, #fff);
  -webkit-box-shadow: 0 0 0 1000px rgba(255, 255, 255, 0.06) inset;
          box-shadow: 0 0 0 1000px rgba(255, 255, 255, 0.06) inset;
  caret-color: var(--ct-orb-gold-lit, #e8c07a);
  transition: background-color 9999s ease-out;
}

.ct-orb-input:focus {
  border-color: rgba(205, 133, 63, 0.85);
}

/* ── ★ THE SEND BUTTON — DELETED (v7) ───────────────────────────────────────
 *
 * The visitor's report: "tell me about the inverted arrow, on the caption windows,
 * what it is, should be removed if not needed". It was the `→` SEND glyph, and the
 * verdict to remove it is right — but for a better reason than "not needed".
 *
 * ── WHAT IT ACTUALLY WAS ──────────────────────────────────────────────────
 * A 30px transparent square holding a 16px gold `<path d="M4 12h13M12 6l6 6-6 6">`:
 * a right arrow that was NOT inverted and NOT broken. It simply READ wrong, because
 * a `→` at the right edge of a field that says "Text or talk…" means either
 * "forward" or "collapse this" to a visitor; "send" is the one reading it does not
 * suggest, and it was repeating a promise the placeholder already makes.
 *
 * ── ★ THE HISTORY MATTERS, BECAUSE THIS WAS DELIBERATELY KEPT TWICE ────────
 *   1. 2026-09-15: "SEND BUTTON — SHRUNK, NOT REMOVED." Kept for MOBILE, where soft
 *      keyboards differ, and as the target screen readers navigate to.
 *   2. It then cost a real bug: its `::after { inset: -7px }` tap target extended
 *      LEFT over the input and painted above it, so `elementFromPoint(inputCentre)`
 *      returned `.ct-orb-send` and **the text field could not be typed into at all**.
 *      The fix was to grow it with padding instead — i.e. the control was made to
 *      work by being pushed away from the thing it was covering.
 * ★ THAT SECOND POINT IS WHY REMOVING IT IS THE RIGHT CALL RATHER THAN A TIDY-UP. A
 * control that has to be positioned carefully so it does not eat its neighbour's
 * hit area is a control that does not belong in that row — the same lesson as the
 * fold control in v6/v6b.
 *
 * ── ⚠ WHAT REPLACES ITS FUNCTION, AND WHY NOTHING IS LOST ──────────────────
 *   • ENTER STILL SUBMITS THE FORM. `<form>` submits natively on Enter with or
 *     without a submit button (verified: no JS references `.ct-orb-send`, and
 *     `_onComposerChanged`/the form's own `submit` handler carry the turn), so
 *     desktop keyboard visitors lose nothing.
 *   • THE ROW IS NOW HONEST ABOUT ITS PURPOSE. It is [field, speaker, minus]: type
 *     here · silence her voice · fold her away. Three controls, each adjacent to
 *     what it acts on.
 *   • ⚠ IF A VISIBLE SEND AFFORDANCE IS EVER NEEDED AGAIN, ADD IT AS AN ICON INSIDE
 *     THE FIELD (a trailing button) — NOT as a fourth flex item, which is the shape
 *     that produced both the overlap bug and the crowding that pushed the fold
 *     control into the corner in v6b.
 *
 * ⚠ ALL `.ct-orb-send` RULES ARE REMOVED, INCLUDING ITS `z-index` LINE IN THE HIT-TEST
 * BLOCK BELOW. Leaving them would be dead CSS that reads as a live intent.
 */
.ct-orb-mute  { position: relative; z-index: 0; }

/* ★ THE FORM IS A POSITIONING CONTEXT, AND THE INPUT MUST WIN THE HIT TEST (v7).
 * These two rules outlived the send button and are KEPT because they are about the
 * row's stacking, not about the arrow. ⚠ `.ct-orb-input` wins with `z-index: 1`
 * against the mute's `0` — without it, the mute's 44px padding-free box sits
 * adjacent to the field and the field loses taps at its right edge. */
.ct-orb-form { position: relative; }
.ct-orb-input { position: relative; z-index: 1; }

/* ══════════════════════════════════════════════════════════════════════════
 * ★ MOBILE: THE FULL-SCREEN STAGE (2026-09-20 visual rebuild)
 *
 * ── WHY NOT THE SAME BAR ON A PHONE ───────────────────────────────────────
 * A 380px bar is 97% of a 390px viewport, so the "bar" is already the full width
 * — and it still offers only four lines of her reply while a thumb covers the
 * bottom third of the screen. The visitor's own instinct was right: "perhaps
 * become full screen for mobile when click the orb and minimize - sign on the
 * corner to reduce to orb again."
 *
 * ── THE SHAPE ─────────────────────────────────────────────────────────────
 *   • The panel becomes the WHOLE viewport, minus the safe areas.
 *   • The orb GROWS TO A HERO (112px) and rises into the stage's upper third. It
 *     stays visible and tappable: the conversation never loses her face, which is
 *     the entire reason this product is an orb and not a chat box.
 *   • Her words become the large text of the screen (17px) — the read-along is
 *     what a phone visitor actually reads.
 *   • The input is pinned to the BOTTOM so it lands just above the soft keyboard.
 *   • The header strip (grip + −) is how the visitor returns to the small
 *     floating orb without reloading a page. `_collapse()` in the loop owns that.
 *
 * ★ WHY `.ct-orb-staged` AND NOT JUST A BREAKPOINT: CSS alone cannot know that
 * the visitor minimised her, and a panel that keeps re-showing itself after a
 * minimise tap is how a − button silently stops working. The class is the single
 * JS-visible fact "the stage is open", and the media query only says "a stage is
 * possible here".
 * ══════════════════════════════════════════════════════════════════════════ */

@media (max-width: 480px) {
  /* ── the orb as the hero of the stage ── */
  #ct-avp-orb.ct-orb-staged {
    width: 112px;
    height: 112px;
    /* Centred horizontally, sitting in the upper third: below the status bar,
       well clear of the keyboard, and not crowding the text beneath it. */
    right: 50%;
    bottom: auto;
    top: max(15vh, calc(env(safe-area-inset-top, 0px) + 64px));
    transform: translateX(50%);
    box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5);
  }
  /* The hover/focus scale must compose with the centring transform, not replace
     it — otherwise the orb jumps half its own width on hover. */
  #ct-avp-orb.ct-orb-staged:hover,
  #ct-avp-orb.ct-orb-staged:focus-visible {
    transform: translateX(50%) scale(1.04);
  }
  #ct-avp-orb.ct-orb-staged .ct-avp-icon { font-size: 44px; }
  /* A 112px hero with the desktop's 6px ring inset would leave the ring looking
     detached; scale the inset with the orb. */
  #ct-avp-orb.ct-orb-staged .ct-avp-ring { inset: -10px; border-width: 3px; }

  /* ── the stage itself ── */
  .ct-orb-panel {
    inset: 0;
    width: auto;
    max-width: none;
    border: none;
    border-radius: 0;
    gap: 10px;
    /* `padding-top` clears the hero orb: its offset + its height + breathing
       room. `max()` keeps the safe-area floor on notched devices.
       ★ THIS SHORTHAND IS ALSO WHAT UNDOES THE DESKTOP DOCK. The docked panel
       carries `padding-bottom: calc(var(--ct-orb-size) + 11px)` (to clear the
       orb's footprint in its bottom-right corner) and a one-sided `border-radius`
       of `16px 999px 999px 16px`. A four-sided `padding` shorthand + the
       `border-radius: 0` above reset BOTH, which is exactly what a full-screen
       stage needs. ⚠ Do not convert this to `padding-top:` only — the stage would
       inherit a 67px dead band at the bottom and a pill-shaped left edge. */
    padding: max(calc(env(safe-area-inset-top, 0px) + 6px),
                 calc(15vh + 112px + 20px))
             14px
             calc(env(safe-area-inset-bottom, 0px) + 12px);
    animation: ct-orb-stage-in 0.26s cubic-bezier(0.22, 1, 0.36, 1);
  }

  /* ── the header strip: grip (left) + − (right), both ≥44px tall ── */
  .ct-orb-mobile-bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex: 0 0 auto;
    min-height: 44px;
    /* Absolute so it sits ABOVE the hero orb rather than pushing it down. */
    position: absolute;
    top: max(env(safe-area-inset-top, 0px), 4px);
    left: 0;
    right: 0;
    padding: 0 10px;
  }

  .ct-orb-grip {
    /* A 40×4 pill: the universal "this can be dismissed / pulled away". */
    width: 40px;
    height: 4px;
    margin-left: 4px;
    border-radius: 999px;
    background: rgba(255, 255, 255, 0.32);
  }

  /* ★ `.ct-orb-min` (44px) AND `.ct-orb-min:hover/:focus-visible` REMOVED
     (2026-10-22) — the phone stage no longer carries a fold control. On a phone the
     fold is the ORB TAP, and the staged orb is a 112px hero, so the target is
     larger than the 44px button it replaced. */

  /* ── her text, at phone-reading size ── */
  .ct-orb-state { font-size: 13px; padding: 0 4px; }
  .ct-orb-caption {
    /* The stage can afford far more than four lines — the cap is now only about
       never hiding the input, and 40vh cannot at any text size. */
    --ct-orb-caption-max: 40vh;
    font-size: 17px;
    line-height: 1.55;
    padding: 0 4px;
  }

  /* Her text is CENTRED in the space between the hero and the input, so the stage
     reads as one composition instead of text clinging to the top. */
  .ct-orb-head {
    flex: 1 1 auto;
    justify-content: center;
    gap: 10px;
    overflow: hidden;
  }

  /* ★ THE INPUT OWNS THE BOTTOM. `margin-top: auto` is explicit here so the input
     stays pinned no matter how long her reply grows — it must never move while
     she speaks. */
  .ct-orb-form { flex: 0 0 auto; margin-top: auto; }
  .ct-orb-options { flex: 0 0 auto; }

  /* ── thumb-sized controls, and no iOS focus zoom ── */
  .ct-orb-input {
    padding: 13px 14px;
    /* ★ 16px is the iOS threshold: at 15px or below, focusing the field zooms the
       whole page in and the visitor has to pinch back out. */
    font-size: 16px;
    border-radius: 999px;
  }
  .ct-orb-row  { gap: 4px; }
  .ct-orb-mute { width: 40px; height: 40px; font-size: 16px; }
  /* ⚠ NO `.ct-orb-send` HERE (v7) — the button is deleted. The fold control on the
     stage is sized by the ≤480px block below, not by this one. */

  /* On a phone there is no hover, so the mute's relevance comes only from the
     engaged/state rules — but it is drawn brighter and larger because it competes
     with a thumb rather than a cursor. */
  .ct-orb-panel .ct-orb-mute { color: rgba(255, 255, 255, 0.85); }
}

@keyframes ct-orb-stage-in {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* ══════════════════════════════════════════════════════════════════════════
 * ★ SHORT VIEWPORT: THE KEYBOARD IS OPEN (2026-09-20, found by screenshot)
 *
 * THE BUG THE SCREENSHOT SHOWED: on a phone the soft keyboard takes roughly half
 * the viewport. At 390×420 the 112px hero orb still sat at `15vh`, which pushed
 * her body onto the TOP LINE of her own reply — she covered the words she was
 * saying, and the panel's `padding-top` (derived from the FULL-height orb) left
 * the text overlapping her portrait.
 *
 * ── THE RULE ──────────────────────────────────────────────────────────────
 * When vertical space is scarce, HER TEXT AND THE INPUT OUTRANK HER FACE. The
 * orb does not disappear — it shrinks back to a clear, tappable presence at the
 * top — because an orb that vanishes when you start typing is a worse loss than
 * one that gets smaller. A conversation is words.
 *
 * `max-height: 520px` is the trigger rather than a JS keyboard detection on
 * purpose: it needs no `visualViewport` listener, cannot get out of sync, and
 * covers every cause of a short viewport (keyboard, landscape, split screen,
 * a small device) with one rule.
 * ══════════════════════════════════════════════════════════════════════════ */

@media (max-width: 480px) and (max-height: 520px) {
  #ct-avp-orb.ct-orb-staged {
    width: 64px;
    height: 64px;
    top: max(calc(env(safe-area-inset-top, 0px) + 8px), 8px);
  }
  #ct-avp-orb.ct-orb-staged .ct-avp-icon { font-size: 26px; }
  #ct-avp-orb.ct-orb-staged .ct-avp-ring { inset: -6px; border-width: 2px; }

  .ct-orb-panel {
    /* Clear the shrunken orb (64px) + its 8px top offset + a real gap, so the
       orb's bottom edge can never reach into the text block. `+ 82px` is
       measured, not guessed: the probe reported `orbBottom=72, headTop=64` with
       the first attempt at `+ 64px`, i.e. the orb still ate 8px of the head. */
    padding-top: calc(env(safe-area-inset-top, 0px) + 82px);
    gap: 6px;
  }
  /* The header strip must not collide with the smaller orb: on a short viewport
     the − moves to the RIGHT EDGE at the same height as the orb's centre, so it
     still reads as "close" without stealing a row of vertical space. */
  .ct-orb-mobile-bar {
    top: max(env(safe-area-inset-top, 0px), 2px);
    align-items: flex-start;
  }
  /* ★ `.ct-orb-min { width: 40px; height: 40px; }` REMOVED (2026-10-22) — the short-
     viewport sizing for a fold button that no longer exists. */

  /* Her words lead. The status line steps back rather than pushing them down. */
  .ct-orb-caption {
    --ct-orb-caption-max: 44vh;
    font-size: 16px;
    line-height: 1.45;
  }
  .ct-orb-state { font-size: 12px; }
  /* A slimmer field so the input always fits above the keyboard. */
  .ct-orb-input { padding: 10px 13px; }
  .ct-orb-mute { width: 36px; height: 36px; }
}

@media (prefers-reduced-motion: reduce) {
  /* ★ `.ct-orb-peek` IS INCLUDED (v7) — the pill is new, and every animated
     surface in this file must be named here or it silently ignores the preference.
     ★ AND `#ct-avp-orb` IS INCLUDED (2026-09-25) — the docked orb now animates its
     width/height on the panel's 0.22s clock so the two glide together instead of
     disagreeing mid-transition. A motion-sensitive visitor must get the resize
     instantly, exactly as they do for the panel. ⚠ The ID is needed because the
     dock rule's selector is `#ct-avp-orb.ct-orb-dock`, which out-specifies a bare
     class here — and the `!important` is what lets this win in the other direction. */
  .ct-orb-panel, .ct-orb-caption, .ct-orb-input, .ct-orb-peek,
  #ct-avp-orb, #ct-avp-orb.ct-orb-dock { transition: none !important; }
}

/* ── ★ SMALL PHONES: the geometry contract's values, overridden once ─────
 * The orb is 52px at a 14px inset on a small screen (both still meet the project's
 * 44px touch minimum), and because `--ct-orb-centre` is DERIVED from those two
 * numbers, the panel's alignment follows automatically:
 *
 *     --ct-orb-centre = 14 + (52 / 2) = 40px
 *
 * ⚠ This is the whole reason the constants are custom properties on `:root`. The
 * old stylesheet hard-coded `right: 18px` on the panel and `right: 14px` on the
 * orb in a media query — two literals that had to be kept in agreement by hand,
 * which is exactly how a 28px misalignment survives a redesign.
 *
 * NOTE: this block sits ABOVE the four-state section deliberately — it is layout
 * only, and the states below own nothing it touches.
 */
@media (max-width: 480px) {
  :root {
    --ct-orb-size: 52px;
    --ct-orb-inset: 12px;
  }
  /* ⚠ The orb's rules in the stage block below must not be undone by this one:
     `.ct-orb-staged` sets its own `right`/`top` at the same specificity and later
     in the file, so the staged hero wins. Verified by geometry probe in
     `qa/orb_visual_test.py` (`V-SIZE-M`). */
}

/* ── the FOUR states ─────────────────────────────────────────────────── */

/* ★ MOTION LANGUAGE (2026-09-15)
 *
 * Each state gets a DISTINCT MOTION, not just a distinct colour. Colour alone
 * fails for colour-blind users and is hard to read at 56px; motion is legible
 * from across a room and needs no legend:
 *
 *   LISTENING → BREATHES  (slow in/out)      "I'm quiet, waiting for you"
 *   THINKING  → TRAVELS   (an arc rotates)   "working on it"
 *   SPEAKING  → RIPPLES   (pulses outward)   "sound is coming out"
 *
 * Text in the caption states the same thing in words, so the two channels
 * reinforce each other instead of competing.
 */

/* LISTENING — the ring breathes: calm, steady, inviting. */
#ct-avp-orb.ct-orb-listening .ct-avp-ring {
  border-color: #4ade80;
  animation: ct-orb-breathe 2.8s ease-in-out infinite;
}
/* ★ HER PORTRAIT IS NO LONGER HAZED WHILE SHE WAITS (2026-09-22, v4).
 *
 * The visitor: "make the hostess image little clearer, as looks like shaded".
 *
 * MEASURED CAUSES, three of them, and this is the largest: the portrait was
 * dropped to `opacity: 0.55` in LISTENING — which is her RESTING state, i.e. the
 * one the visitor looks at most of the time. She was at 55% brightness by default
 * and only reached full brightness mid-sentence.
 *
 * WHY 0.55 WAS THERE, AND WHY IT IS SAFE TO RAISE. It was the "your turn to talk"
 * cue. That cue is NOT lost by raising it, because it was never carried by this
 * opacity alone — the ring already encodes every state in MOTION, which the
 * "MOTION LANGUAGE" block above documents as the primary channel *precisely
 * because* colour and brightness fail (colour-blind users, a 56px target read from
 * across a room):
 *
 *     LISTENING → BREATHES (green, slow in/out)
 *     THINKING  → TRAVELS  (yellow arc)
 *     SPEAKING  → RIPPLES  (gold, outward)
 *
 * So the dimming was a SECOND, weaker channel for a signal that already has a
 * stronger one. Keeping a small step (0.85, not 1.0) preserves a hint of the
 * contrast between "waiting" and "talking" without the murk the visitor reported.
 *
 * ⚠ DO NOT DELETE THE RULE ENTIRELY. A flat 1.0 in every state removes the last
 * brightness difference between LISTENING and SPEAKING, and the `.clinerules`
 * record for this component treats the state legibility as load-bearing. 0.85 is
 * the deliberate middle: clear enough to read her face, distinct enough to matter. */
#ct-avp-orb.ct-orb-listening .ct-avp-icon {
  opacity: 0.85;                     /* clear, with a hint of "your turn to talk" */
}

/* THINKING — a travelling arc: unmistakably "busy", never mistaken for calm. */
#ct-avp-orb.ct-orb-thinking .ct-avp-ring {
  border-color: #facc15;
  border-top-color: transparent;
  border-right-color: transparent;
  animation: ct-orb-travel 0.9s linear infinite;
}
#ct-avp-orb.ct-orb-thinking .ct-avp-icon {
  opacity: 0.85;                     /* was 0.7 — see the LISTENING note above */
}

/* SPEAKING — the ring ripples outward: sound leaving her. */
#ct-avp-orb.ct-orb-speaking .ct-avp-ring {
  border-color: #cd853f;
  animation: ct-orb-ripple 1.6s ease-out infinite;
}
#ct-avp-orb.ct-orb-speaking .ct-avp-icon {
  opacity: 1;                        /* full brightness: "I'm talking" */
}

/* GREETING — her intro. Visually identical to SPEAKING (she IS speaking) but
   kept as its own hook so onboarding can be styled without touching state. */
#ct-avp-orb.ct-orb-greeting .ct-avp-ring {
  border-color: #cd853f;
  animation: ct-orb-ripple 1.6s ease-out infinite;
}
#ct-avp-orb.ct-orb-greeting .ct-avp-icon { opacity: 1; }

/* IDLE (no state class) — still ring, full-brightness portrait. */
#ct-avp-orb .ct-avp-icon { opacity: 1; }

/* The portrait itself gets a soft transition so the brightness change reads as
   "she responds" rather than a flicker. */
#ct-avp-orb .ct-avp-icon {
  transition: opacity 0.35s ease;
}

@keyframes ct-orb-breathe {
  0%   { transform: scale(1);    opacity: 0.85; }
  50%  { transform: scale(1.09); opacity: 1;    }
  100% { transform: scale(1);    opacity: 0.85; }
}

@keyframes ct-orb-travel {
  to { transform: rotate(360deg); }
}

@keyframes ct-orb-ripple {
  0%   { transform: scale(1);    opacity: 0.9; }
  70%  { transform: scale(1.13); opacity: 0.25; }
  100% { transform: scale(1.18); opacity: 0;    }
}

/* ── accessibility: motion off means no motion ───────────────────────── */

@media (prefers-reduced-motion: reduce) {
  #ct-avp-orb,
  #ct-avp-orb .ct-avp-ring,
  #ct-avp-orb .ct-avp-icon,
  #ct-avp-orb .ct-avp-label,
  .ct-orb-panel,
  .ct-orb-peek,
  .ct-orb-head,
  .ct-orb-caption,
  .ct-orb-state,
  .ct-orb-input,
  .ct-orb-mute {
    animation: none !important;
    transition: none !important;
  }
  /* Without motion, colour + portrait brightness still tell the story. */
  #ct-avp-orb.ct-orb-listening .ct-avp-ring { border-color: #4ade80; opacity: 1; }
  #ct-avp-orb.ct-orb-thinking  .ct-avp-ring { border-color: #facc15; opacity: 1; }
  #ct-avp-orb.ct-orb-speaking  .ct-avp-ring { border-color: #cd853f; opacity: 1; }
  #ct-avp-orb.ct-orb-greeting  .ct-avp-ring { border-color: #cd853f; opacity: 1; }
}

/* ── small screens: keep the orb clear of the thumb zone ─────────────── */

@media (max-width: 480px) {
  #ct-avp-orb {
    width: 52px;
    height: 52px;   /* still >= 44px touch minimum */
    right: max(14px, env(safe-area-inset-right, 0px));
    bottom: max(14px, env(safe-area-inset-bottom, 0px));
  }
  #ct-avp-orb .ct-avp-label {
    font-size: 12px;
    max-width: 62vw;
    white-space: normal;
  }
}

/* ══════════════════════════════════════════════════════════════════════════
 * ★ QUESTIONS WITH OPTIONS (2026-09-19)
 *
 * When she does not know what the visitor wants she ASKS — and offers two or
 * three tap-able answers, so the visitor never has to compose a sentence to say
 * "yes" or "show me the famous people".
 *
 * ── DESIGN CONSTRAINTS (deliberate, do not "improve" these away) ──────────
 *   • The row ships `hidden` and is only revealed when options exist, so the
 *     panel is unchanged until she actually asks (OB-16: no dead UI).
 *   • Chips are quiet outlines, not gold blocks — the ORB is the interactive
 *     hero of this panel, and OB-17 keeps the send button quiet for the same
 *     reason. A row of filled buttons would read as a menu, which is exactly
 *     what she must NOT feel like.
 *   • Touch targets are >= 40px tall even though the text is small, because
 *     these are the primary way a phone visitor answers her question.
 *   • Wraps rather than scrolls: at most three short labels, so a wrap is
 *     always tidier than a horizontal scroller that hides the third option.
 * ══════════════════════════════════════════════════════════════════════════ */

.ct-orb-options {
  display: flex;
  flex-wrap: wrap;
  gap: 7px;
  margin: 0 0 9px;
  /* The panel is `position: fixed` over page content; keep the chips in the
   * panel's own stacking context so they can never float over the orb. */
  position: relative;
  z-index: 1;
}

.ct-orb-options[hidden] { display: none; }

/* ══════════════════════════════════════════════════════════════════════════
 * ★★ THE CHIPS MUST WEAR THE THEME'S TEXT COLOUR — AND WIN THE CASCADE (2026-09-26)
 *
 * Reported: *"the option buttons … are hard to read, as dark on dark, example,
 * pickup where we left off button."*
 *
 * TWO INDEPENDENT FAULTS, and fixing only one leaves it broken:
 *
 * 1. ⚠ THE COLOUR WAS HARDCODED (`#f4f4f5`) — the ONLY orb text surface that did
 *    not use the theme's text token. Every sibling (caption, input, placeholder)
 *    reads it, so the chips were the single element that could not follow a theme.
 *    On **Ivory** the panel is near-white and `--ct-orb-text` is `#1a1a1c`, so a
 *    hardcoded near-white would be invisible there — the bug mirrored, not fixed.
 *
 * 2. ⚠⚠ THE RULE LOST THE CASCADE TO THE THEME. Neve ships
 *        .btn, .button.button-primary, … button, input[type="button"] { color: … }
 *    `.button.button-primary` is specificity **(0,2,0)**; a bare `.ct-orb-option` is
 *    **(0,1,0)**, so it loses **even though `ct-orb.css` loads last**. MEASURED:
 *    the computed colour was `rgb(26, 32, 44)` — near-black — while this rule
 *    declared `#f4f4f5`. The browser's own UA sheet (`button { color: fieldtext }`)
 *    supplies the near-black that makes that loss *readable as* "dark on dark".
 *    Was it `!important`? NO — proven by injecting the same colour INLINE, which
 *    resolved correctly both plain and with `!important`. It is purely a
 *    specificity contest, so the remedy is the SAME ONE THE PLACEHOLDER NEEDED:
 *    an ID-scoped pair. See `#ct-orb-panel .ct-orb-input::placeholder` below.
 *
 * ⚠ A FILL IS PART OF READABILITY, NOT DECORATION. The chips were
 * `background: transparent` on a panel whose colour VARIES BY THEME, so a chip was
 * never separated from what sat behind it — half of why it read as text-on-nothing
 * rather than as a button. The fill is derived from `--ct-orb-text`, so it lightens
 * the surface on a dark theme and darkens it on a light one, automatically.
 * ══════════════════════════════════════════════════════════════════════════ */

.ct-orb-option {
  flex: 0 1 auto;
  max-width: 100%;
  min-height: 40px;
  padding: 9px 13px;
  border: 1px solid color-mix(in srgb, var(--ct-orb-gold, #cd853f) 70%, transparent);
  border-radius: 999px;                          /* pill = "an answer", not a tab */
  /* ⚠ `color-mix` degrades to the white literal where unsupported — the case that
   * was already correct. Same reasoning as the placeholder's fallback above. */
  background: color-mix(in srgb, var(--ct-orb-text, #fff) 10%, transparent);
  color: var(--ct-orb-text, #fff);
  /* ⚠ RESTATED, NOT REDUNDANT. On some engines the UA's button paint is a separate
   * `-webkit-text-fill-color`, so `color` alone leaves the near-black pairing in
   * place. The caption and the input both carry this pair for the same reason. */
  -webkit-text-fill-color: var(--ct-orb-text, #fff);
  font: inherit;
  font-size: 13.5px;
  line-height: 1.25;
  text-align: left;
  cursor: pointer;
  touch-action: manipulation;
  transition: background 0.15s ease, border-color 0.15s ease;
}

/* ⚠ THE ID-SCOPED PAIR — THIS IS WHAT ACTUALLY WINS (0,2,0 ties the theme, and
 * `ct-orb.css` loads after it). Raising specificity is preferred over `!important`
 * so the hover/active states below stay cleanly overridable; `pages.css` documents
 * the same Neve fight and reaches for `!important` only because it must also beat
 * INLINE styles, which we do not have here. */
#ct-orb-panel .ct-orb-option {
  background: color-mix(in srgb, var(--ct-orb-text, #fff) 10%, transparent);
  color: var(--ct-orb-text, #fff);
  -webkit-text-fill-color: var(--ct-orb-text, #fff);
  border-color: color-mix(in srgb, var(--ct-orb-gold, #cd853f) 70%, transparent);
}

/* ★ THE HOVER/ACTIVE FILLS ARE TOKEN-DERIVED TOO. Leaving them as hardcoded gold
 * `rgba(205,133,63,…)` would re-introduce this exact bug class on any theme whose
 * accent differs: the resting chip would follow the palette and the hover would
 * snap back to bronze. Every state must move together, or the theme is only
 * half-applied — the same lesson as the whisper tier's four surface layers. */
.ct-orb-option:hover,
.ct-orb-option:focus-visible,
#ct-orb-panel .ct-orb-option:hover,
#ct-orb-panel .ct-orb-option:focus-visible {
  background: color-mix(in srgb, var(--ct-orb-gold, #cd853f) 22%, transparent);
  border-color: var(--ct-orb-gold-lit, #e09a52);
  color: var(--ct-orb-text, #fff);
  -webkit-text-fill-color: var(--ct-orb-text, #fff);
  outline: none;
}

/* A pressed chip gives immediate feedback even before the turn returns, so the
 * tap never feels ignored (the "quick thinking" half of the brief). */
.ct-orb-option:active,
#ct-orb-panel .ct-orb-option:active {
  background: color-mix(in srgb, var(--ct-orb-gold, #cd853f) 34%, transparent);
  color: var(--ct-orb-text, #fff);
  -webkit-text-fill-color: var(--ct-orb-text, #fff);
}

@media (max-width: 480px) {
  .ct-orb-options { gap: 6px; margin-bottom: 8px; }
  .ct-orb-option  { font-size: 13px; padding: 9px 12px; }
}

