/* voice.css — the microphone-vad-transformersjs component restyled for the
   shell's bottom bar. Hand-synced pair: ui-components/checkbox-svg/voice.css
   <-> site/voice.css (keep identical). The behavior contract is unchanged —
   same ids/classes the component's JS binds to (#mic, #mic-rec, .voice-wave,
   .utterances, .utt-*, .exchanges, …); only the layout differs from the /talk
   page's centered pill:
     .voice-top   — the always-visible band; its strand canvas SIZES the
                    collapsed bar (120px, one height in both states), mic
                    record toggle at the right
     .voice-lower — revealed by the bar's own toggle (it is a .sidebar-content,
                    so the shell's checked gate shows it): the config menu and
                    the utterance overview side by side on the bar's floor —
                    two lists of one shape — with the conversation track above.

   State selectors are the component's own, re-scoped from its `~ .cluster`
   pill wrappers to `~ .voice-top` / `~ .voice-lower`. */

:root {
  /* the voice component's token set. site/index.html declares a subset of
     these for the landing stage with the SAME values — keep them agreeing */
  --fg: #f5f5f7;
  /* white alpha ramp */
  --w02: rgba(255,255,255,.02); --w04: rgba(255,255,255,.04);
  --w10: rgba(255,255,255,.10); --w18: rgba(255,255,255,.18);
  --w22: rgba(255,255,255,.22); --w30: rgba(255,255,255,.30);
  --w40: rgba(255,255,255,.40); --w92: rgba(255,255,255,.92);
  /* recording live glow */
  --glow-live: 0 0 0 1px var(--w04), 0 0 38px -8px rgba(180,120,255,.55);
  --accent: #8B5CF6;
  /* the one colour that means "switched off", not "busy" — the speaker's mute
     slash (prototyping/active_audio_ui.html uses the same value) */
  --muted-red: #ef4444;
  /* the speaker glyph's box, and the level bar beside it: how far it runs out
     over the strand, and the gap it leaves off the glyph. The length is a
     number now that the bar lies along the band rather than across it —
     nothing about a horizontal overlay's length is implied by what it covers,
     and running out over the strand it is not boxed in by anything either. */
  --spk-size: 30px;
  --vol-gap: 8px;
  --vol-len: 120px;
  /* motion */
  --ease-spring: cubic-bezier(.2,.8,.2,1);
  --t-fast: .28s; --t-med: .4s;
  /* type */
  --track-label: .32em;
  /* the strand band's height — the collapsed bar IS this. Shared, because the
     bar's toggle label has to be exactly this tall to sit on the canvas's
     mid-line (see .bottom-bar .sidebar-label). */
  --voice-band-h: 120px;
  /* utterance geometry: the chip, the clip column it opens, and the body of
     the clip you pick sit in ONE horizontal flex line, each a fixed width.
     --utt-row-h is the collapsed row height, deliberately the same box as the
     overview chip that opens the column. */
  --utt-row-h: 30px;
  --utt-row-gap: 6px;
  --utt-gap: 10px;
  /* how far the clips dissolve into whichever edge of their window still has
     clips hidden past it (see @keyframes utt-edge-fades) */
  --utt-fade-h: 22px;
  --utt-list-w: 176px;
  /* the open card's body is a ROW, restated: same width, so the line reads as
     one kind of module repeated rather than a panel bolted onto a column */
  --utt-panel-w: var(--utt-list-w);
  /* how many clips the window shows at once; the rest scroll */
  --utt-rows-shown: 1.5;
  --utt-list-h: calc(var(--utt-rows-shown) * var(--utt-row-h)
                     + (var(--utt-rows-shown) - 1) * var(--utt-row-gap));
  /* ...and the SAME height as one, which is the whole of the geometry now: the
     body is an audiogram and nothing else, so it is sized as the module it has
     become rather than as a panel with contents to fit. Opening a card still
     costs the section nothing — it was never taller than the column beside it,
     and a row's box cannot be. */
  --utt-panel-h: var(--utt-row-h);
}

/* ---- state inputs: visually hidden, still focusable ---- */
.ui-state {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px;
  clip-path: inset(50%);
  overflow: hidden;
}

/* ---- layout root inside the bar ---- */
.mic {
  flex: 1;
  align-self: stretch;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  box-sizing: content-box;
  font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
  /* chrome policy, not content policy: the transcript/reply opt back in */
  user-select: none;
  height: fit-content;
}

/* the component's styles were written against a zeroed baseline (its page
   carries a global reset); recreate that baseline scoped to the component so
   menu/ol/h3 UA defaults don't leak into the bar. Every rule below that needs
   a margin/padding states it explicitly. */
.mic *,
.mic *::before,
.mic *::after {
  margin: 0;
  padding: 0;
  /* border-box belongs to that same baseline: the component's own page sets it
     globally and its sizes assume it. The site page has the same global reset,
     the checkbox-svg harness page does NOT, so without this line the two hosts
     size the bar differently — and the chip/row heights below have to agree to
     the pixel for the list to read as a stack of the chip. */
  box-sizing: border-box;
}

/* ---- always-visible band: the strand IS the bar, mic at the right ---- */
.voice-top {
  height: fit-content; /* this band sizes the collapsed bar (the canvas below) */
  display: flex;
  align-items: center;
  justify-content: space-around;
  padding-right: 10px;
  width: 100%;
}

/* The bar's own toggle, sized to the band beside it. shell.css centres the
   glyph inside this box and aligns the box to the top of the bar, so matching
   the band's height puts the arrow on the canvas's mid-line — and keeps it
   there when the lower section opens and the bar grows underneath. */
.bottom-bar .sidebar-label {
  height: var(--voice-band-h);
}

/* ---- opening and closing the bar, as one movement ----
   shell.css already transitions this container's height, and it never ran:
   `height: fit-content` is the specified value in BOTH states, so only the
   USED value changed when the lower section appeared, and transitions fire on
   computed-value changes. The bar therefore snapped to full height instantly
   and the section's opacity faded in inside an already-open box — while
   closing did the reverse (fade first, snap after, once there was nothing left
   to see). That asymmetry is what made one direction look smooth and the other
   look like nothing at all.

   Two lines fix both. The collapsed height is now STATED — it is the strand
   band, the same figure shell.css's comment says it works out to — so there
   is a real value to travel from; and interpolate-size lets the other end stay
   `fit-content` rather than a second number that would have to be kept in step
   with whatever the section contains. The band then glides rather than jumps,
   which is the movement of the top panel, and the fade rides along with it. */
.bottom-bar .sidebar-container {
  interpolate-size: allow-keywords;
}
.bottom-bar .sidebar-checkbox:not(:checked) ~ .sidebar-container {
  height: var(--voice-band-h);
}

/* The canvas sizes the collapsed BAR, at one fixed height in both bar states.
   In-flow, spanning the band minus the toggle label. Idle (no recording => nothing
   drawn), the background paints a faint end-fading baseline where the strands
   will live. strand-wave.js refits its bitmap only on WINDOW resize, so the
   drawing stretches across an open/close until the next refit.
   The toggle used to grow this to 250px — that was the strand standing in for
   the retired lower section. The section is back, so the toggle's height goes
   to the clips instead and the strand keeps one size. */
.voice-wave {
  display: flex;
  /* the band minus whatever the controls at its two ends take. It was a
     calc() against the toggle label alone; with the speaker now at the left
     end, flex is the version that cannot go stale — strand-wave.js refits its
     bitmap to whatever box this element ends up with. */
  flex: 1;
  min-width: 0;
  height: var(--voice-band-h);
  background: linear-gradient(90deg,
      transparent, var(--w18) 12%, var(--w30) 50%, var(--w18) 88%, transparent)
    center / 100% 1.5px no-repeat;
}
#mic-rec:checked ~ .voice-top .voice-wave { background: none; }

/* ---- speaker + volume, at the band's LEFT end (prototyping/
        active_audio_ui.html, #spk-btn / #vol-wrap, with its speaking-jiggle
        and ray-fade left behind — the rays are simply on, and the only state
        this carries is muted or not).

        The bar is a HORIZONTAL run beside the glyph, lying over the strand,
        and the glyph stays exactly where it was: on the band's centre line,
        which is the strand's line too. The strand does not move when you hover
        — nothing is pushed aside, the bar simply covers a stretch of a canvas
        that is idle whenever you are reaching for the volume — and the band
        stays the one height it was.

        Horizontal because ACROSS the band it could never be long enough: the
        glyph sits on the centre line, which leaves half a band less half a
        glyph of headroom above it, and a bar may not leave the band (the bar
        is clipped — .sidebar-container, shell.css). Along the band there is no
        such ceiling.

        NO POSITIONING IS INVOLVED, and none is needed. Two ordinary
        properties do it:

          - the bar is laid out in flow, at its real width, and then hands that
            width straight back with negative margin. The gap is a real margin
            too, so it falls outside the plate rather than inside it, and the
            give-back cancels both: they sum to zero with the bar's own width,
            so .spk measures the GLYPH alone and the strand starts where it
            always did.
          - a flex ITEM may raise itself with z-index while still
            position:static (flexbox §4.3 — items paint as inline blocks, and
            a z-index other than auto makes a stacking context regardless of
            position). Without it the canvas, being later in document order,
            would paint over the bar.

        Staying in flow is the whole point: this box is still measured, still
        clipped, and still moved by the layout around it, so nothing added to
        the band later has to know it exists. The one thing to know is that it
        does not RESERVE its width — put another control to the LEFT of the
        speaker, or the bar will cover it while it is up. ---- */
.spk {
  flex: none;
  display: flex;
  align-items: center;
  padding: 0 6px;
  /* a flex item can do this with no position; see above */
  z-index: 1;
}
.vol-wrap {
  /* laid out at its width, then given back — no space taken, nothing moved.
     8 + 120 - 128 = 0, so .spk is the glyph's box and the strand holds its x. */
  width: var(--vol-len);
  margin-left: var(--vol-gap);
  margin-right: calc(-1 * (var(--vol-len) + var(--vol-gap)));
  display: flex;
  align-items: center;
  /* an opaque plate, so the strand behind it is OUT rather than showing
     through: a level you are setting should not be read against a moving
     waveform. Square and unbordered — it is a mask over the canvas, not
     another one of the bar's pills. */
  padding: 8px 10px;
  background: #000;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t-fast);
}
/* Up while the pointer is anywhere on the control — the glyph or the bar,
   since using it means crossing from one to the other.

   NOT :focus-within, which was the bug: a range keeps focus after you drag it,
   so the bar stayed up once set and only went away when you clicked something
   else. Setting a level is finished when you look away from it. :focus-visible
   is the keyboard's way in and does not have that problem, because a pointer
   drag does not make focus visible — the two paths stop overlapping.

   :active covers the drag itself: the pointer can stray off a 19px plate while
   dragging, and the bar must not vanish under the thumb you are holding. */
.spk:hover .vol-wrap,
.spk:has(.vol:active) .vol-wrap,
.spk:has(:focus-visible) .vol-wrap { opacity: 1; pointer-events: auto; }
.vol {
  appearance: none;
  -webkit-appearance: none;
  width: 100%;
  height: 3px;
  margin: 0;
  background: var(--w18);
  border-radius: 2px;
  cursor: pointer;
}
.vol::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  width: 11px; height: 11px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 8px rgba(140,120,255,.7);
}
.vol::-moz-range-thumb {
  width: 11px; height: 11px;
  border: none; border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 8px rgba(140,120,255,.7);
}
.vol:focus-visible { outline: 2px solid rgba(140,120,255,.7); outline-offset: 3px; }

.spk-btn {
  flex: none;
  display: grid;
  place-items: center;
  width: var(--spk-size); height: var(--spk-size);
  border-radius: 8px;
  cursor: pointer;
  color: var(--fg);
  transition: background var(--t-fast);
}
.spk-btn:hover { background: var(--w04); }
.spk-btn svg { display: block; width: 22px; height: 22px; overflow: visible; }
/* both voice-out rays are the DEFAULT state: this glyph says "sound comes out
   of here", and it only ever changes to say that it doesn't. */
.spk-slash {
  stroke: var(--muted-red);
  stroke-width: 2;
  stroke-linecap: round;
  opacity: 0;
  transition: opacity var(--t-fast);
}
#spk-mute:checked ~ .voice-top .spk-slash { opacity: 1; }
#spk-mute:checked ~ .voice-top .spk-body,
#spk-mute:checked ~ .voice-top .spk-rays { opacity: .28; }
#spk-mute:focus-visible ~ .voice-top .spk-btn {
  outline: 2px solid rgba(140,120,255,.7);
  outline-offset: 2px;
}

/* ---- segment button (the mic record toggle — the only one left) ---- */
.split-seg {
  display: grid;
  align-items: flex-end;
  cursor: pointer;
  flex-shrink: 0;
  transition: background var(--t-fast);
}
.split-seg:hover { background: var(--w04); }
.split-seg svg {
  transition: transform var(--t-fast) var(--ease-spring), opacity var(--t-fast);
  width: 26px;
  height: 26px;
}

/* record segment hover/press feedback */
.split-seg[for="mic-rec"]:hover svg { transform: scale(1.1); }
.split-seg[for="mic-rec"]:active svg { transform: scale(1.04); }

/* keyboard focus projected onto the visible segment. One segment left: the
   config opener it used to share this rule with is gone, and the config's own
   controls are real inputs that carry their own focus ring. */
#mic-rec:focus-visible ~ .voice-top .split-seg[for="mic-rec"] {
  outline: 2px solid rgba(140,120,255,.7);
  outline-offset: 3px;
}

/* ---- recording state ---- */
/* the recording indicator IS the mic glyph: red outline + pulse, and the
   live glow (which framed the whole pill on /talk) rings the toggle here */
#mic-rec:checked ~ .voice-top .split-seg[for="mic-rec"] {
  border-radius: 50%;
  box-shadow: var(--glow-live);
}
#mic-rec:checked ~ .voice-top .icon-path {
  fill: none;
  stroke: red;
  animation: rec-pulse 2.4s var(--ease-spring) infinite;
}
@keyframes rec-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.6; }
}

/* mute is a remembered preference with no live control in this increment; the
   checkbox stays in the DOM (mic-access.js disables the track on it) but is
   kept out of the tab order until a recording session exists */
#mic-rec:not(:checked) ~ #mic-mute { visibility: hidden; }

/* ---- expanded area: conversation over [config row | utterance overview] ----
   A .sidebar-content, so the bar's own checkbox reveals it (display/opacity
   machinery in the shell stylesheet); this rule re-flows it from the
   horizontal bars' column flow into its own grid.

   TWO rows, not three: the config menu and the overview share the LAST one,
   side by side. Stacked, they would each cost the other its height — and they
   are now the same object twice (two --utt-rows-shown scrollers), so side by
   side is also what makes the floor read as one line rather than two lists.

   The controls take the bottom track and the spare height goes to THEM, not to
   the conversation above (see grid-template-rows). The floor is still a fixed
   line — the chip and the newest clip sit on it at any section height, because
   the cluster is `align-self: end` — but the height above that line now belongs
   to the menu beside it instead of to a band of nothing. */
.bottom-bar .voice-lower {
  flex: 1;
  /* the section is --utt-rows-shown rows tall from the outset and stays there:
     the clip column already holds that height with one clip in it, and this
     floor covers the case before the first clip lands (the overview chip is
     hidden at count 0), so the bar never jumps as clips arrive. + the 4px/10px
     block padding below and the 10px row gap to the conversation track. */
  min-height: calc(var(--utt-list-h) + 24px);
  width: 100%;
  grid-auto-flow: row;
  /* THE FLOOR TRACK TAKES THE SLACK. It used to be the other way round — the
     conversation track was the 1fr and every spare pixel collected ABOVE the
     controls — which was right while the controls were fixed-height things
     that would otherwise have floated up the bar leaving dead space beneath
     them. Neither is true now: the clip cluster is `align-self: end` so it
     stands on the floor at any track height, and the config fills its track by
     construction. Leaving the slack up top only bought a band of nothing
     between the bar's ceiling and the menu, which grew with every pixel the
     section was given and which the menu could not reach.

     So: row 1 is content-sized (the conversation is display:none, so zero
     today) and row 2 takes everything left over. Give the section more height
     and the MENU gets it. Its minimum is still a clip column — the config
     contributes no height of its own, so without that floor it would collapse
     before the first clip lands, exactly when the chip is hidden at count 0.

     Un-hiding .exchanges later means revisiting this: bounded by `auto` it
     would size to its content rather than to the bar. */
  grid-template-rows: minmax(0, auto) minmax(var(--utt-list-h), 1fr);
  /* TWO columns: the config, then the utterance cluster. They used to share one
     cell, so an open panel sat on top of the chip and its list.

     THREE tracks: the config, the cluster, and an empty mirror. Every width here
     is derived — nothing about the panel's size is written down, so it survives a
     font change, a new config row, or a translation.

     The two side tracks are both 1fr, i.e. EQUAL, which puts the middle track's
     centre on the section's centre: the cluster is page-centred without anyone
     having to know how wide the config is. The middle is `auto`, so it is exactly
     the cluster.

     `auto` as each side track's MINIMUM is what makes it adapt. While half the
     free space is enough for the panel, both gutters take that half and the
     cluster does not move when the panel opens. When it isn't, column 1 holds its
     content instead and the mirror gives up its share — so the middle slides just
     far enough right to sit FLUSH against the panel, never on it. Page-centred
     until it can't be, then beside the menu, with the changeover falling out of
     track sizing rather than a breakpoint. */
  grid-template-columns: minmax(auto, 1fr) auto minmax(auto, 1fr);
  justify-items: center;
  align-items: stretch;
  /* COLUMN gap only. A row gap here is 10px of nothing above the menu for as
     long as the conversation track is empty — which is always, today, since
     .exchanges is display:none — and a grid still gaps a zero-height track. Any
     future view that un-hides the conversation should carry its own spacing
     (a margin on .exchanges), so the cost lands on the thing that needs it. */
  column-gap: 10px;
  row-gap: 0;
  /* one scroller: .exchanges scrolls internally (exchanges.js pins its own
     scrollTop); this box must never become the scroller above it */
  overflow: hidden;
  /* symmetric again — the empty third track is the mirror now, so the padding
     goes back to being just the section's own inset. (Do not add a `transition`
     to this rule: it would replace the whole shorthand shell.css sets on
     .sidebar-content and take the section's opacity/display fade with it.) */
  padding: 4px 12px 10px;
  /* No `display` here on purpose. This box IS a .sidebar-content, so the shell
     stylesheet already owns when it shows: display:none collapsed, display:grid
     under .sidebar-checkbox:checked (which is the grid the rules above are
     written for). Restating it here would only fight that gate — which is
     exactly what the two rules that used to sit here did, to keep the section
     retired while the bar was just the strand. */
}

/* Column 1 of the bottom row. The menu sizes this column and the utterance
   cluster in column 2 is pushed along by exactly that much — which is the
   point: the two can never overlap. Pinned to the start, so the menu sits on
   the bar's left edge. */
.voice-config-row {
  grid-row: 2;
  grid-column: 1;
  /* a COLUMN, so the menu's flex-basis governs its HEIGHT (see .config): it
     contributes none and grows into whatever the row is. align-items:flex-start
     keeps the cross axis — the width — content-sized, which is what sizes this
     grid column and pushes the clip cluster along. The row is stretched by the
     grid (align-items:stretch on .voice-lower), so "whatever the row is" is a
     real height by the time the menu grows into it. */
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-self: start;
  min-height: 0;
}

.bottom-bar .exchanges { grid-row: 1; grid-column: 1 / -1; }

/* ---- config menu: NOT a disclosure. It used to hide behind a mic-settings
        button and grow inline from 0 width, which put a click between the user
        and three settings on a bar they had already opened to get at them.
        Opening the bar IS opening it, so the button and its #mic-config-open
        checkbox are gone from the markup, and the menu simply stands on the
        floor in column 1 — a second list beside the clip column, the same
        height, the same rows, the same feathered scroll (see THE SCROLL WINDOW
        above, which it shares with .utterances rather than restating).

        It stays a MENU BOX though — one rectangular surface with its groups
        ruled off inside it, not a stack of pills. Only two things are borrowed
        from the clip list: the type (see .config-group) and the scroll window.
        A menu is a menu; it reads as one object because it is one. ---- */
.config {
  list-style: none;
  /* THE MENU HOLDS ITS WIDTH. Column 1 of .voice-lower is minmax(auto, 1fr),
     whose `auto` minimum is meant to stop the cluster ever squeezing this —
     but the automatic minimum size of a SCROLL CONTAINER is zero, and this box
     became one when it took the feathered scroller above. So the minimum was
     nothing, and opening the clip list crushed the menu from 338px to 152px
     (the menu then being fit-content, it shrank with the column and scrolled
     its own rows sideways). Stating the minimum gives the track something to
     defend: the cluster now stops beside the menu instead of pushing into it,
     and it is the empty third track that gives up its share. */
  min-width: max-content;
  /* the menu OWNS the two columns; each group borrows them back with subgrid
     (see .config-group), which is what lines the option runs up across groups.
     Both tracks are content-sized, so the box stays fit-content and no width
     is written down anywhere. */
  display: grid;
  grid-template-columns: auto auto;
  width: fit-content;
  background: rgba(5,5,7,.72);
}

/* ── utterance cards: a VERTICAL column, opened by the overview chip and
      sitting to the RIGHT of it. Anchored to the BOTTOM of the bar and growing
      UPWARD: the newest clip is always the bottom row, level with the chip,
      and each new one pushes the stack up rather than reaching further down.

      Exactly --utt-rows-shown rows tall, ALWAYS — a fixed height, not a cap.
      Older clips scroll off the top rather than being dropped, and the mask
      dissolves them into the top edge on the way out. So the bar's height is a
      constant, not a function of how long you talk.

      The rows are ALL that lives in here. The open card's body is parked in
      .utt-stage beside this box (utterances.js), because a body in here would
      scroll away with the rows, be clipped by the box it overflows, and be cut
      out by the mask below — a mask applies to everything its element paints,
      overflow included. ── */
.utterances {
  list-style: none;
  display: flex;
  flex-direction: column;
  align-items: flex-start; /* rows keep their own width rather than stretching */
  gap: var(--utt-row-gap);
  width: fit-content;
  min-width: 0;
  flex: 0 1 auto;
  /* the step off the chip. A margin here rather than `gap` on .utt-overview:
     a shut <details> can still generate a content box, and a gap would notch
     the chip's right edge even with no clips on show. */
  margin-left: var(--utt-gap);
}

/* ── THE SCROLL WINDOW, and there are two of them ──
      The clip column and the config menu are the same object at different
      contents: a fixed --utt-rows-shown rows tall, scrolling internally, with
      each edge feathered only while something is actually hidden past it. So
      the behaviour is written once, here, and both lists take it — the bar's
      floor then holds two boxes of one height whatever is in either, which is
      the whole reason the section's height is a constant.

      A soft edge MEANS "there is more this way", so it has to be earned: the
      two lengths are driven by scroll position in @keyframes utt-edge-fades
      below, and both are 0 at rest. That is what makes a list which doesn't
      overflow crisp at BOTH ends — a scroll() timeline with no scroll range is
      inactive, so the animation doesn't apply and these keep their initial 0px.

      Nothing that must stay legible may live inside either box: a mask applies
      to everything its element paints, overflow included (which is why the open
      card's body is parked in .utt-stage, outside the clip list). ── */
.utterances,
.config {
  overflow-y: auto;
  overscroll-behavior: contain;
  /* No scrollbar — the mask is the affordance instead. (Firefox + WebKit/
     Blink; the ::-webkit- rule is below.) */
  scrollbar-width: none;
  -webkit-mask-image: linear-gradient(to bottom,
      transparent 0, #000 var(--utt-fade-t),
      #000 calc(100% - var(--utt-fade-b)), transparent 100%);
  mask-image: linear-gradient(to bottom,
      transparent 0, #000 var(--utt-fade-t),
      #000 calc(100% - var(--utt-fade-b)), transparent 100%);
  /* longhands, not the `animation` shorthand: the shorthand resets
     animation-timeline back to the document timeline. */
  animation-name: utt-edge-fades;
  animation-duration: auto; /* `auto` = the timeline's whole scroll range */
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: scroll(self block);
}
.utterances::-webkit-scrollbar,
.config::-webkit-scrollbar { display: none; }
/* ── which of the two SETS the height, and which TAKES it ──
   The clip column states it: --utt-rows-shown rows, a fixed box, and the
   floor's height is therefore a property of the clips.

   The config does the opposite. `flex-basis: 0` means it contributes NO height
   to the row it sits in — so its three groups cannot push the bar taller, the
   way a written-down height was the only thing stopping them before — and
   `flex-grow: 1` then fills whatever that row turned out to be. Change
   --utt-rows-shown, give the bar more room, add a fourth config group: the
   menu is whatever is on offer and scrolls the remainder, with no second
   number to keep in step. This is the whole of the reversal; the feathered
   scroll above needs no height of its own, only a box that overflows. */
.utterances { height: var(--utt-list-h); }
.config { flex: 1 1 0; min-height: 0; }
/* The two feather lengths. Registered so they interpolate as <length> — an
   unregistered custom property is a token soup the keyframes could only swap
   discretely, not tween. 0 is "clean cut", and it is the resting value, so any
   engine that ignores scroll timelines still gets a sane static edge. */
@property --utt-fade-t {
  syntax: "<length>"; inherits: false; initial-value: 0px;
}
@property --utt-fade-b {
  syntax: "<length>"; inherits: false; initial-value: 0px;
}
/* Scroll position -> which ends are feathered. The 1% ramps are what keep this
   honest as a BINARY signal: a hair of scroll and the edge you just left goes
   soft, and it only firms up again when you are hard against that end. Between
   them both ends stay soft, because clips are hidden both ways.
   Percentages that name a property only at some keyframes hold it flat across
   the others, so --utt-fade-t is full from 1% all the way to 100% and
   --utt-fade-b is full from 0% to 99%. */
@keyframes utt-edge-fades {
  0%   { --utt-fade-t: 0px; --utt-fade-b: var(--utt-fade-h); }  /* at the top */
  1%   { --utt-fade-t: var(--utt-fade-h); }
  99%  { --utt-fade-b: var(--utt-fade-h); }
  100% { --utt-fade-t: var(--utt-fade-h); --utt-fade-b: 0px; }  /* at the bottom */
}
.utterances:empty { display: none; }
.utterances > li {
  flex: 0 0 auto; /* rows keep their height; the column never squeezes them */
}
/* Bottom-anchors a list too SHORT to fill the window, so the first few clips
   stack up from the floor instead of hanging off the ceiling. Once the list
   outgrows the window there is no free space, the auto margin resolves to
   zero, and it behaves as an ordinary scroller. (justify-content: flex-end
   would do the short case too — but it makes the overflow unreachable: a flex
   container produces no scrollable region past its START edge, so the older
   clips could never be scrolled back to.) */
.utterances > li:first-child { margin-top: auto; }
@media (prefers-reduced-motion: no-preference) {
  /* enters from BELOW, the direction the stack travels: a new clip appears on
     the floor and lifts the ones above it. .exchange keeps the X slide
     (utt-in), which reads fine for a stack that is not itself a scroll axis. */
  .utterances > li { animation: utt-in-y var(--t-med) var(--ease-spring); }
  @keyframes utt-in-y {
    from { opacity: 0; transform: translateY(6px); }
    to   { opacity: 1; transform: none; }
  }
  @keyframes utt-in {
    from { opacity: 0; transform: translateX(10px); }
    to   { opacity: 1; transform: none; }
  }
}
/* In the bar the card is ONLY its row: utterances.js lifts the body out to
   .utt-stage the moment the card opens, and puts it back in here (where a shut
   <details> hides it) on close. So this box is just the pill — the pill
   treatment lives on the summary rather than here for that reason, and one
   card's state can no longer affect the height of any other. */
.utterance {
  display: block;
  min-width: 0;
}
.utterance > summary {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: none;
  min-width: var(--utt-list-w); 
  min-height: var(--utt-row-h);
  padding: 3px 8px 3px 12px;
  cursor: pointer;
  list-style: none;
  font-size: 10.5px;
  letter-spacing: .1em; /* tighter than body labels so the row stays compact */
  text-transform: uppercase;
  color: var(--w30);
  border: 1px solid var(--w10);
  /* a pill, like the chip whose height it now shares: a row is that same box
     with a circular button at its right end */
  border-radius: 999px;
  background: var(--w02);
  transition: color var(--t-fast), border-color var(--t-fast),
              background var(--t-fast), box-shadow var(--t-fast);
}
.utterance > summary::-webkit-details-marker { display: none; }
.utterance > summary:hover { color: var(--w92); border-color: var(--w18); background: var(--w04); }
/* OPEN: the row takes the same lit treatment the overview chip takes when IT
   opens — "this is the thing you opened", said the same way at both levels */
.utterance[open] > summary {
  color: var(--w92);
  border-color: var(--w40);
  background: var(--w04);
  box-shadow: 0 0 0 1px var(--w04), 0 12px 32px -16px rgba(0,0,0,.85);
}
/* the labels hold their size; the sent label is the one that can run long
   ("✕ reason"), so it is the one that gives way inside a fixed-width row */
.utterance > summary .utt-offset,
.utterance > summary .utt-dur { flex: none; }
.utterance > summary .utt-sent {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* every button in the clip UI is scaled to the row's height, and to ONE size:
   play and download are the row's two controls, they sit side by side, and a
   pair that reads as a pair cannot be two sizes. The 32px default is for hosts
   that give the component a page rather than a bar. */
.utterances .card-btn { width: 18px; height: 18px; }
.utterances .card-btn svg { width: 12px; height: 12px; }
/* offset from stream start: the row's headline, where the counter used to sit
   — brightest, and now the card's whole identity, since the body says nothing
   at all. Still scoped to the summary: it is the one label that outranks the
   duration beside it, and the selector says which of the two that is. */
.utterance > summary .utt-offset { color: var(--w92); }
.utt-dur { color: var(--w30); }
/* upload status: the info glyph (icons/status/info.svg) — white while the clip
   is local or accepted, orange once the server has REFUSED one, which is the
   only state worth a colour of its own. The wording is the hover tooltip (a
   native title, so neither the row column nor the staged body can clip it)
   plus a hidden copy for AT. */
/* Present ONLY when the newest clip of the turn was refused (utterances.js
   paintSent): a healthy upload has nothing to say, so the mark is absent rather
   than reassuring, and it clears itself the moment the next clip comes back
   accepted. display:none, not opacity — a mark nobody can see must not hold a
   slot in the row either, which is now a tight one. */
.utt-sent { display: none; }
.utt-sent[data-state="rejected"] {
  display: inline-grid;
  place-items: center;
  flex-shrink: 0;
  color: darkorange;
  transition: color var(--t-fast);
}
.utt-sent svg { display: block; width: 12px; height: 12px; }
.utt-sent-text {
  width: 1px; height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
/* the row's two actions as ONE cluster at the right end: the row's 8px gap
   falls before the cluster, and inside it the pair sits closer than that, so
   they read as two controls belonging together rather than two more labels */
.utt-actions { display: flex; align-items: center; margin-left: auto; }
.utt-toggle { margin-left: auto; } /* alone (no cluster) it still pins right */
.utt-actions .utt-toggle { margin-left: 0; } /* the cluster carries the auto */
.utt-dl { text-decoration: none; }
.card-btn {
  display: grid;
  place-items: center;
  width: 32px; height: 32px;
  border-radius: 50%;
  cursor: pointer;
  color: var(--fg, #f5f5f7);
  transition: background var(--t-fast);
}
.card-btn:hover { background: var(--w04); }
.card-btn svg {
  width: 19px; height: 19px;
  opacity: .7;
  transition: opacity var(--t-fast), transform var(--t-fast) var(--ease-spring);
}
.card-btn:hover svg { opacity: 1; transform: scale(1.1); }
@media (max-width: 600px) {
  .card-btn { width: 26px; height: 26px; }
  .card-btn svg { width: 15px; height: 15px; }
  /* leaner overview chip on narrow viewports (drop the "clips" word + the
     speech figure) so the summary fits the bar */
  .ov-clips-label, .ov-sep-speech, .ov-speech { display: none; }
}
/* play <-> pause swap driven by the per-card checkbox */
li > .utt-play:not(:checked) ~ details .icon-pause { display: none; }
li > .utt-play:checked ~ details .icon-play { display: none; }
li > .utt-play:checked ~ details .utt-toggle svg { opacity: 1; }
li > .utt-play:focus-visible ~ details .utt-toggle {
  outline: 2px solid rgba(140,120,255,.7);
  outline-offset: 2px;
}

/* ── audiogram (wave-viz.js), details-only + LAZY: two stacked copies (the
      SoundCloud two-copy technique) — a base and a vivid copy clipped to the
      played fraction. --p (0..1) is the ONLY JS-published signal (set on the
      li from audio timeupdate); everything else renders in CSS. At rest the
      base shows the full-color spectral fingerprint; while the card PLAYS
      (.utt-play checked) the base recedes to engraved silver and the vivid
      copy sweeps color back in behind the playhead. The SVG is injected on
      first open, so these rules bind once .utt-wave-full exists. ── */
.utt-wave { position: relative; display: block; }
.utt-wave > span { display: block; height: 100%; }
.utt-wave .wave-base { transition: filter var(--t-med); }
li > .utt-play:checked ~ details .utt-wave .wave-base {
  filter: grayscale(.9) brightness(1.5) opacity(.4);
}
.utt-wave .wave-live {
  position: absolute;
  inset: 0;
  clip-path: inset(0 calc(100% - (var(--p, 0) * 100%)) 0 0);
  transition: clip-path .3s linear; /* smooths ~4 Hz timeupdate into a sweep */
}
.utt-wave svg { display: block; width: 100%; height: 100%; }
/* The audiogram is the card body's only VISIBLE part, so it absorbs the whole
   of the panel's fixed height. flex:1 rather than 100%: the two elements
   beside it are display:none today and this holds either way if one of them is
   ever shown again. */
.utt-audiogram { flex: 1; min-height: 0; }
.utt-wave-full { width: 100%; height: 100%; }
/* the playing card's swept region gets a gentle amber glow lift */
li > .utt-play:checked ~ details .utt-wave .wave-live svg {
  filter: drop-shadow(0 0 7px rgba(249,140,10,.4));
}

/* ── the stage: the open clip's body, parked here by utterances.js. A third
      item on the same flex line as the chip and the column, sitting on the
      bar's floor — so the card is in the SAME place whichever row you opened
      and however far the list is scrolled, which is the whole reason the body
      does not live in the list. Same height as the column beside it, so
      opening a card costs the section nothing. ── */
.utt-stage {
  align-self: flex-end;
  margin-left: var(--utt-gap);
}
/* no clip open, no box — and so no stray gap on the line either */
.utt-stage:empty { display: none; }

/* ── the card body: ONE row's box, holding one audiogram ──
   Same width and height as a summary row, and the same lit treatment an open
   row takes — the panel on the floor is that row, said again where nothing
   scrolls it away, with the waveform in place of the labels. Everything else
   the body used to carry now lives in the row itself, which is why this can be
   a box that small: the labels and the controls did not move here, they never
   left. */
.utt-body {
  flex: none;
  width: var(--utt-panel-w);
  height: var(--utt-panel-h);
  display: flex;
  flex-direction: column;
  /* nothing here can scroll — the one visible child is sized to the box — but
     the rounding has to cut the waveform's corners rather than the wave
     overhanging them */
  overflow: hidden;
  border: 1px solid var(--w10);
  border-radius: 12px;
  box-shadow: 0 0 0 1px var(--w04), 0 12px 32px -16px rgba(0,0,0,.85);
}
/* No entrance animation on the staged body: opening a clip is a READ, and the
   list is walked one row after another, so a slide-and-fade on every open is
   latency between the click and the waveform. The card appears at once. */

/* ── the staged body's play state ──────────────────────────────────────────
   The rules further up reach the body through `li > .utt-play:checked ~ details`
   — a sibling relation that only holds while the body is parked inside its
   card. Staged, it has no relation to that checkbox at all, so the waveform's
   two playing states are restated here against the ONE card that can be open
   (the <details name> accordion guarantees it), matched with :has() from their
   common ancestor. Only the waveform: the play/pause icons stayed behind in
   the row, which never leaves the list and so never left that sibling reach. ── */
.utt-overview:has(.utt-play:checked + .utterance[open]) .utt-stage .wave-base {
  filter: grayscale(.9) brightness(1.5) opacity(.4);
}
.utt-overview:has(.utt-play:checked + .utterance[open]) .utt-stage .wave-live svg {
  filter: drop-shadow(0 0 7px rgba(249,140,10,.4));
}

/* No narrow-bar override any more: the line was chip | rows | card with the
   card the widest box on it, and the card is now a row's width, so there is
   nothing left to trim that isn't already as narrow as the rows themselves. */

/* The <audio> element STAYS — it is the card's player, not a decoration: the
   row's play/pause button calls play()/pause() on it and the audiogram's
   colour sweep is driven off its timeupdate. Only its native transport bar is gone;
   display:none does not stop a media element from playing. (Deleting the
   element from utterances.js instead would throw at `audio.src = url` and no
   card would render at all.) */
.utt-body audio { display: none; }

/* ── best-effort local transcript: a hidden SLOT, not a panel ──
      Every card still asks the whisper worker for its turn's text and the
      answer still lands in this element (utterances.js requestTranscript) —
      it is simply never shown, here or on the component's own page. Hidden in
      CSS rather than dropped from the card, because the text is PUBLISHED:
      a shell embedding this component reads the transcript off this node. ── */
.utt-tx { display: none; }

/* ── overview chip: a collapsed <details> whose <summary> IS the counter +
      live cumulative stats. Collapsed by DEFAULT, so the clip column
      (ol.utterances) is hidden until the user opens it; the summary keeps
      updating while collapsed so activity stays visible. Hidden entirely
      until the first clip (data-count, written by JS). ── */
.utt-overview {
  min-width: 0;
  /* Kept inside its own column, so any overspill goes RIGHT — away from the
     panel. The column is content-sized so this is normally a no-op; it earns its
     keep when the bar is too narrow for the tracks and the grid overflows,
     where the alternative is the cluster reaching back over the config. */
  max-width: 100%;
  /* centred in the bar (justify-items on .voice-lower), sharing the floor with
     the config menu rather than sitting under it. */
  grid-row: 2;
  /* column 2: its own track, so the config menu can never reach it. The chip
     still centres (justify-items on .voice-lower) — just within this column
     rather than the whole bar. */
  grid-column: 2;
  /* the bar's floor is the baseline everything here is measured from: the chip
     sits on it, and the clip column stands on it too and grows upward */
  align-self: end;
  /* the chip and the column it opens are a flex LINE, not a stack: clicking
     the chip opens the clips to its RIGHT. Everything downstream — the rows,
     and the body of whichever row you pick — extends the same line. */
  display: flex;
  align-items: flex-end;
}
/* Newer engines wrap a <details>'s content in this pseudo-element, which lands
   the clip column and the stage inside ONE flex item and stacks them as blocks
   — the stage drops below the list instead of sitting beside it. Making the
   wrapper itself a flex line restores the row. Engines without the pseudo
   ignore this and already have the two as direct flex children above, so both
   arrangements produce the same layout. */
.utt-overview::details-content {
  display: flex;
  align-items: flex-end;
}
.utt-overview:not([data-count]),
.utt-overview[data-count="0"] { display: none; }

.utt-overview > summary {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: none;
  width: fit-content; /* the chip hugs its content in both states */
  /* NO auto margins any more: the chip is the first item of a flex line now
     (see .utt-overview), where `margin-inline: auto` would shove the clip
     column away to the far end of it. What the chip is centred WITHIN changed
     with it — the bar centres the whole open cluster, chip included, rather
     than the chip alone. */
  /* 18px badge + 5px block padding + the 1px border = --utt-row-h, the figure
     every row in the column beside it matches. min-height holds it there when
     the stats are still empty. */
  min-height: var(--utt-row-h);
  padding: 5px 12px 5px 9px;
  list-style: none;
  cursor: pointer;
  border: 1px solid var(--w10);
  border-radius: 999px;
  background: var(--w02);
  color: var(--w30);
  white-space: nowrap;
  transition: color var(--t-fast), border-color var(--t-fast),
              background var(--t-fast), box-shadow var(--t-fast);
}
.utt-overview > summary::-webkit-details-marker { display: none; }
.utt-overview > summary:hover { border-color: var(--w18); background: var(--w04); color: var(--w92); }
/* OPEN: the chip lifts into the same selected treatment an opened row gets in
   the column (.utterance[open] > summary) — lit border, filled surface, soft drop. Same
   tokens on purpose: "this is the thing you opened" should look identical
   whether it is the overview or one clip inside it. This is what tells you the
   chip is open, now that the chevron no longer turns. */
.utt-overview[open] > summary {
  border-color: var(--w40);
  background: var(--w04);
  color: var(--w92);
  box-shadow: 0 0 0 1px var(--w04), 0 12px 32px -16px rgba(0,0,0,.85);
}
.utt-overview > summary:focus-visible {
  outline: 2px solid rgba(140,120,255,.7);
  outline-offset: 2px;
}
/* disclosure chevron: points right when collapsed, rotates down on open.
   Colour inherits from the chip, so it brightens with the rest of it on hover
   and open rather than tracking its own states — the chip's highlight and this
   rotation now say the same thing together. */
.ov-marker {
  display: inline-flex;
  color: inherit;
  transition: transform var(--t-fast) var(--ease-spring);
}
.ov-marker svg { display: block; width: 12px; height: 12px; }

/* cumulative stat cluster — the running totals are summed in JS (utterances.js)
   because CSS counters are source-order-bound and the summary precedes the
   items it would need to sum; the tracked-caps micro-labels are the house style */
.ov-stats {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 10.5px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: white;
}
.ov-sep { opacity: .4; }
.ov-size { color: var(--w92); } /* captured size: the headline figure */

/* utterance count badge (fed by data-count) — the leading pill of the
   overview summary; kept as .strip-count so the existing JS/data-count hook
   and its attr() content still work verbatim */
.strip-count {
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  display: grid;
  place-items: center;
  border: 1px solid var(--w18);
  border-radius: 999px;
  font-size: 10px;
  color: var(--w92);
  transition: transform var(--t-fast) var(--ease-spring);
}
.strip-count::before { content: attr(data-count); }
.strip-count[data-count="0"] { display: none; }

/* "Muted" state label beside the strand (shown only if the dormant mute state
   ever flips; kept for the streaming increment) */
.stream-label {
  font-size: 10.5px;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--w30);
}
.stream-label-muted { display: none; }
#mic-mute:checked ~ .voice-top .stream-label-muted { display: inline; color: rgba(239,68,68,.7); }

/* ── conversation: the reply side of /voice. One EXCHANGE per uploaded clip
      — the server pipeline is stateless single-turn, so each exchange is
      independent: user turn (clip ref + the same grey best-effort local
      transcript as the card) over the agent's reply. Deliberately NOT gated
      on recording: a reply must survive the mic being released (you stop,
      then read); a NEW recording session clears it (mic-access.js →
      Exchanges.clear on start, not stop). Visibility is content-driven — no
      exchanges, no node at all (:empty): nothing dormant is ever shown. ── */
.exchanges {
  list-style: none;
  display: none;
  flex-direction: column;
  gap: 10px;
  width: min(560px, 100%);
  /* bounded by the bar — long conversations scroll IN HERE (exchanges.js pins
     this element's scrollTop to the newest reply), never the box above */
  max-height: 100%;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--w18) transparent;
}
.exchanges:empty { display: none; }
.exchanges::-webkit-scrollbar { width: 6px; }
.exchanges::-webkit-scrollbar-track { background: transparent; }
.exchanges::-webkit-scrollbar-thumb { background: var(--w18); border-radius: 999px; }
.exchanges:hover::-webkit-scrollbar-thumb { background: var(--w30); }

.exchange {
  border: 1px solid var(--w10);
  border-radius: 12px;
  background: var(--w02);
  padding: 12px 14px;
  transition: border-color var(--t-fast), background var(--t-fast);
}
.exchange:hover { border-color: var(--w18); background: var(--w04); }
@media (prefers-reduced-motion: no-preference) {
  .exchange { animation: utt-in var(--t-med) var(--ease-spring); }
}

/* user turn: tracked-caps identity chips, then the transcript. The
   transcript keeps the card's grammar — grey best-effort machine text,
   italics while pending/failed — but is CONTENT, so it is selectable
   (the chrome's user-select:none is chrome policy, not content policy). */
.ex-user {
  display: flex;
  align-items: baseline;
  gap: 10px;
  font-size: 10.5px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--w30);
}
.ex-label { letter-spacing: var(--track-label); }
.ex-clip { color: var(--w92); letter-spacing: .06em; }
.ex-tx {
  display: block;
  margin-top: 6px;
  font-size: 12.5px;
  line-height: 1.5;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--w30);
  white-space: normal;
  user-select: text;
}
.ex-tx:empty { display: none; }
.ex-tx[data-state="pending"],
.ex-tx[data-state="error"] { color: var(--w22); font-style: italic; }

/* agent turn: the reply is the headline content — bright, readable,
   selectable; the model's own line breaks are meaning (pre-wrap) */
.ex-agent {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--w04);
}
.ex-reply {
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--w92);
  white-space: pre-wrap;
  overflow-wrap: break-word;
  user-select: text;
}
/* awaiting the reply: a pulsing ellipsis, pure CSS off data-state (reuses
   the rec-pulse keyframes — same "alive, in progress" vocabulary) */
.exchange[data-state="pending"] .ex-reply::after {
  content: "· · ·";
  color: var(--w30);
  animation: rec-pulse 1.6s var(--ease-spring) infinite;
}
/* typed failure: muted red + ✕ prefix, same grammar as the card's
   rejected sent-label (JS writes only the bare reason text) */
.exchange[data-state="error"] .ex-reply {
  font-size: 12px;
  color: rgba(239,68,68,.85);
}
.exchange[data-state="error"] .ex-reply::before { content: "✕ "; }
/* passive monitoring signals (model · tokens · latency): exist only once a
   reply does — a pending/failed exchange has no meta to show */
.ex-meta {
  font-size: 10.5px;
  letter-spacing: .06em;
  color: var(--w30);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.exchange:not([data-state="done"]) .ex-meta { display: none; }
@media (max-width: 600px) {
  .exchange { padding: 10px 12px; }
}

/* Each group is ONE LINE — its heading beside its options, not above them —
   and one clip row tall, so the menu scrolls at the same rhythm the clip list
   does and the two boxes on the floor agree line for line. Stacked the three
   groups came to ~265px and stretched the whole lower section past the clip
   column; ruled off at a row's height they scroll inside it instead.

   TWO COLUMNS, heading | options. The groups are grid items spanning both of
   the menu's tracks and taking those same tracks back with `subgrid`, so the
   heading column is sized by the LONGEST heading and every group's options
   begin on that one line. This is why it isn't just a width on the h3: the
   column measures itself against the actual strings, so a renamed group or a
   translation re-aligns the menu instead of overrunning the number someone
   guessed. (The `auto auto` before it is the fallback an engine without
   subgrid keeps: still one line per group, just each sized on its own.) */
.config-group {
  display: grid;
  grid-column: 1 / -1;
  grid-template-columns: auto auto;
  grid-template-columns: subgrid;
  align-items: center;
  gap: 12px;
  min-height: var(--utt-row-h);
  padding: 3px 10px 3px 12px;
  /* the clip row's type, to the letter: one size, one tracking, upper case.
     Set on the group so the heading and its options inherit it together and
     colour stays the only thing telling them apart. */
  font-size: 10.5px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--w30);
}
/* the groups are ruled off from each other, the menu-box way — no gap between
   them, so the surface stays one surface */
.config-group + .config-group { border-top: 1px solid var(--w04); }
.config-group h3 {
  font-weight: 400;
  font-size: inherit;
  letter-spacing: inherit;
}
/* column 2: the group's options, however many it has, on one line. It exists
   so the group occupies exactly TWO cells — without it a group's second option
   would wrap onto the heading's column in the next row. */
.config-opts {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
}

/* ONE OPTION IS ONE <label>, and a label is all an option is — no class for
   it, because inside .config-opts a label is the only thing there is. It has
   to be one label PER control: a label binds to the first labelable element
   inside it, so a single one wrapped round a whole run made every click in
   that run set the first option and turned the run into one hit target. The
   padding is each option's own, which is what makes its text clickable with
   the box rather than making the line clickable. */
.config-opts label {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 6px;
  border-radius: 8px;
  color: var(--w92);   /* the option you can set is the lit part of the line */
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--t-fast);
}
.config-opts label:hover { background: var(--w04); }
.config-opts input { accent-color: var(--accent); }
/* the slider wants real width, and its label is a flex item of .config-opts
   rather than a full-width block */
.config-opts label:has(input[type="range"]) { flex: 1 1 auto; min-width: 0; }
.config-opts input[type="range"] { flex: 1; min-width: 90px; }
