/* svp-app.css — small extras on top of svp-tokens.css
   Components that don't belong in the token sheet:
   - Toast notifications (server-fired via HX-Trigger svp:toast)
   - Save flash animation for form fields
*/

/* Alpine: hide x-cloak elements until Alpine initialises (no flash of
   pre-init state, e.g. the TOTP recovery-code field on the sign-in page). */
[x-cloak] {
  display: none !important;
}

/* ── Toast ─────────────────────────────────────────────────────────── */
#svp-toasts {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 400;
  /* above the nav-loader blur (300) — a toast must never be occluded */
  display: flex;
  flex-direction: column;
  gap: 8px;
  align-items: center;
  pointer-events: none;
  /* the fixed layer doesn't block clicks; .svp-toast re-enables */
}

.svp-toast {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 320px;
  padding: 12px 18px;
  background: var(--bg-2);
  border: 1px solid var(--gold-tint-3);
  border-radius: var(--r-3);
  box-shadow: var(--shadow-pop);
  color: var(--text-1);
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
}

.svp-toast.is-in {
  opacity: 1;
  transform: translateY(0);
}

/* leave the way it arrived — drop down off the screen + fade (a plain vanish
   read as a glitch). Accelerates downward, leaning into the toast metaphor. */
.svp-toast.is-out {
  opacity: 0;
  transform: translateY(140%);
  transition: opacity 240ms ease-in, transform 320ms cubic-bezier(.4, 0, .85, 1);
}

/* Tone-aware presence: info/gold keeps the subtle default; warn/danger/ok carry
   the STRONGER toast palette (not the muted pill statuses) on the border + wash,
   so a warning reads as a warning and an error reads as an error — not a near-
   gold blur into the dark page. (The dot matches, below.) */
.svp-toast[data-tone="warn"] {
  border-color: var(--toast-warn);
  background: color-mix(in srgb, var(--toast-warn) 12%, var(--bg-2));
}

.svp-toast[data-tone="danger"] {
  border-color: var(--toast-danger);
  background: color-mix(in srgb, var(--toast-danger) 14%, var(--bg-2));
}

.svp-toast[data-tone="ok"] {
  border-color: var(--toast-ok);
  background: color-mix(in srgb, var(--toast-ok) 12%, var(--bg-2));
}

.svp-toast-dot {
  width: 10px;
  height: 10px;
  border-radius: 5px;
  background: var(--gold-fill);
  box-shadow: 0 0 0 4px var(--gold-tint);
  flex-shrink: 0;
}

.svp-toast-dot[data-tone="warn"] {
  background: var(--toast-warn);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--toast-warn) 24%, transparent);
}

.svp-toast-dot[data-tone="ok"] {
  background: var(--toast-ok);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--toast-ok) 24%, transparent);
}

.svp-toast-dot[data-tone="danger"] {
  background: var(--toast-danger);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--toast-danger) 24%, transparent);
}

.svp-toast-body {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.svp-toast-body strong {
  font-size: 13px;
  font-weight: 600;
}

.svp-toast-body small {
  font-size: 12px;
  color: var(--text-2);
}

/* reduced motion: keep the fade, drop the slide-up/drop-off (the rest of the app
   honours this — the toast shouldn't be the one exception). */
@media (prefers-reduced-motion: reduce) {

  .svp-toast,
  .svp-toast.is-in,
  .svp-toast.is-out {
    transform: none;
    transition: opacity 200ms var(--ease-out);
  }
}

/* ── Save flash ────────────────────────────────────────────────────── */
@keyframes svp-flash {
  0% {
    box-shadow: 0 0 0 0 var(--gold-tint-2);
  }

  60% {
    box-shadow: 0 0 0 6px var(--gold-tint);
  }

  100% {
    box-shadow: 0 0 0 0 transparent;
  }
}

.svp-flash {
  animation: svp-flash 800ms var(--ease-out);
}

/* ── Spinner ──────────────────────────────────────────────────────────
   A small in-progress spin for a full-page action that's navigating away
   (the context switcher's Switch button) — reassures the click landed. */
@keyframes svp-spin {
  to {
    transform: rotate(1turn);
  }
}

.svp-spin {
  display: inline-flex;
  animation: svp-spin 0.7s linear infinite;
}

/* Reduced-motion: freeze the spinner — the busy label ("Signing in…", "Verifying…")
   still conveys that work is in flight, so no meaning is lost, just the rotation. */
@media (prefers-reduced-motion: reduce) {
  .svp-spin {
    animation: none;
  }
}

/* ── Theme switcher ───────────────────────────────────────────────────
   Dark / light / device icon toggle (sidebar footer + account page). btn-quiet
   icon buttons; the active choice takes a gold-tinted selected state that reads
   in both themes. Server renders the initial active button (from the `theme`
   global); svp.js keeps it in sync on change. */
.svp-theme-switch {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.svp-theme-switch__label {
  font-size: 9.5px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-3);
}

.svp-theme-switch__btns {
  display: flex;
  gap: 2px;
}

.svp-theme-switch__btn {
  padding: 0 8px;
}

.svp-theme-switch__btn.is-active {
  background: var(--gold-tint);
  color: var(--gold-10);
  border-color: var(--border-gold);
}

.svp-theme-switch__btn.is-active:hover {
  background: var(--gold-tint-2);
  color: var(--gold-12);
}

/* Compact variant (topbar): no "Theme" word-label, and never wrap — just the
   three icon buttons sitting inline in the top bar's right cluster. */
.svp-theme-switch--compact {
  flex-wrap: nowrap;
}

/* ── Tooltip (instant, rich) ─────────────────────────────────────────
   SVP's own hover/focus tooltip — instant (no native `title` hover delay),
   token-styled (so it themes dark/light for free), and a11y-wired: the host
   carries aria-describedby → the .svp-tip[role="tooltip"], and is focusable so
   keyboard users get it too. Rich multi-line content (title · body · meta), so
   it renders real markup rather than a plain attr() string.

   Pure CSS, no JS: revealed on the host's :hover / :focus-within. The bubble
   anchors to a .svp-tip-anchor wrapper (NOT each host) and is capped at that
   wrapper's width (max-width:100%) — so inside a scrolling/clipping container
   (e.g. the validation form's overflow:auto context rail) it can never spill
   past the edge and get clipped. On the journey rail the anchor is the row of
   stations, so every station's tooltip appears in one consistent spot just below
   the rail, titled with that station's name. pointer-events:none so the
   (non-interactive) bubble never eats a hover. Reusable on any host inside an
   anchor — a consumer wanting each tooltip under its OWN host (e.g. a row of
   icon buttons) gives each host its own anchor; a shared anchor (like the rail)
   intentionally pools the tooltips at the anchor's bottom-left. */
.svp-tip-anchor {
  position: relative;
}

/* The host keeps the global :focus-visible ring (a clear keyboard indicator on
   the dot) AND reveals the bubble on focus — two cues, no a11y regression. */
.svp-tip {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  z-index: 50;
  width: max-content;
  max-width: 100%;
  /* never wider than the anchor → never clips its container */
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 8px 11px;
  text-align: left;
  /* Reset host TEXT styling the tooltip must not inherit (it's a fresh surface, not
     part of the host's text run):
       - white-space: a tip inside a .btn would inherit nowrap and overflow its
         max-width instead of wrapping (Matt's UAT, #188).
       - font-family: a tip inside a .mono host (e.g. the users-table "+N" badge)
         would render in the code font instead of the UI sans (Matt's UAT, #191). */
  white-space: normal;
  font-family: var(--font-sans);
  background: var(--bg-2);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-2);
  box-shadow: var(--shadow-pop);
  opacity: 0;
  pointer-events: none;
  transition: opacity 120ms var(--ease-out);
}

.svp-tip-host:hover .svp-tip,
.svp-tip-host:focus-within .svp-tip {
  opacity: 1;
  z-index: 51;
}

.svp-tip__title {
  font-size: 12px;
  font-weight: 600;
  color: var(--text-1);
  letter-spacing: -0.005em;
}

.svp-tip__body {
  font-size: 11.5px;
  line-height: 1.4;
  color: var(--text-2);
}

.svp-tip__meta {
  font-size: 11px;
  color: var(--text-3);
  font-variant-numeric: tabular-nums;
}

/* Solo placement — the host is its OWN anchor (put .svp-tip-anchor on the control
   itself) and the bubble centres above it. For single icon-only or labelled
   controls, vs the rail's shared-anchor bottom-left pooling. A short label fits
   comfortably; the cap keeps a longer one from sprawling.

   a11y idiom (differs from the rail, on purpose): a solo control names ITSELF via
   aria-label and marks its .svp-tip aria-hidden — the bubble is a decorative visual
   duplicate of the name, so it mustn't be announced twice. The shared-anchor rail
   instead wires aria-describedby → role="tooltip" because its hosts (the dots) have
   no intrinsic label and the tip's rich text IS the description.
   (A centred --below mirror lands the day a top-of-viewport control would clip.) */
.svp-tip--above {
  top: auto;
  bottom: calc(100% + 8px);
  left: 50%;
  right: auto;
  transform: translateX(-50%);
  max-width: 220px;
}

/* To the right of the host, vertically centred on it — the robust placement for a
   control pinned to the LEFT edge of a clipping container (e.g. a column-1 table
   icon in an overflow:hidden card): it grows rightward (never clips left) and a
   short tip stays within the row's height (never clips top/bottom, any row). */
.svp-tip--right {
  top: 50%;
  bottom: auto;
  left: calc(100% + 8px);
  right: auto;
  transform: translateY(-50%);
  max-width: 220px;
}

/* The mirror of --right: to the LEFT of the host, for a control pinned to the
   RIGHT edge of a clipping container (e.g. the row-end icon buttons in the
   overflow:hidden status-admin cards) — it grows leftward into the card. */
.svp-tip--left {
  top: 50%;
  bottom: auto;
  left: auto;
  right: calc(100% + 8px);
  transform: translateY(-50%);
  max-width: 220px;
}

/* Above the host but LEFT-aligned (grows rightward), for a control near the LEFT
   edge of its container where a centred --above would spill off-edge — e.g. the
   drawer-footer Delete. Wider cap: this one often carries an explanatory sentence
   (the disabled-Delete "why"), not a short label, so it reads in fewer lines. */
.svp-tip--above-start {
  top: auto;
  bottom: calc(100% + 8px);
  left: 0;
  right: auto;
  transform: none;
  max-width: 260px;
}

/* The centred mirror of --above, BELOW the host — for a top-of-viewport control
   (the topbar theme switcher) where an --above tip would clip off the top edge.
   This is the "--below mirror" the --above note foresaw; earned by the theme
   switcher's move into the topbar. */
.svp-tip--below {
  top: calc(100% + 8px);
  bottom: auto;
  left: 50%;
  right: auto;
  transform: translateX(-50%);
  max-width: 220px;
}

/* ── Grade picker (radios styled as a segmented control) ─────────────
   Used in validated qualifications table. When a grade is amended, the
   ORIGINAL student estimate stays visible inside the picker with a
   dashed outline + tiny EST caption underneath — so the eye doesn't
   have to leave the selector to see what was changed. */
.svp-grade-picker {
  /* flex-wrap so the pills drop to a second row when the column is genuinely
     tight (narrow panels) instead of overflowing — only kicks in when needed,
     so wide views are unaffected. row-gap separates wrapped rows; col-gap 0
     keeps the pills visually connected as one segmented control. */
  display: inline-flex;
  flex-wrap: wrap;
  padding: 2px;
  gap: 3px 0;
  background: var(--bg-3);
  border-radius: var(--r-2);
  border: 1px solid var(--border-soft);
  position: relative;
}

.svp-grade-picker input {
  /* visually hidden, still focusable for a11y */
  position: absolute;
  opacity: 0;
  pointer-events: none;
  width: 1px;
  height: 1px;
}

.svp-grade-picker label {
  position: relative;
  height: 22px;
  padding: 0 8px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 11.5px;
  font-weight: 500;
  color: var(--text-3);
  border-radius: 4px;
  cursor: pointer;
  transition: background 120ms, color 120ms;
}

.svp-grade-picker label:hover {
  color: var(--text-1);
}

/* Selection is :checked-driven, so it moves the moment a grade is clicked
   (pre-save UI state, no JS). Persistence is a later concern (WS4 3.4.5). */
.svp-grade-picker input:checked+label {
  background: var(--bg-1);
  color: var(--text-1);
  font-weight: 600;
  box-shadow: 0 1px 0 var(--surface-press) inset, 0 1px 2px rgba(0, 0, 0, 0.18);
}

.svp-grade-picker input:not(:checked)+label.is-original {
  outline: 1.5px dashed var(--text-3);
  outline-offset: -2px;
  color: var(--text-2);
}

.svp-grade-picker input:focus-visible+label {
  outline: 2px solid var(--gold-10);
  outline-offset: 2px;
}

/* The grade-picker cell: the control + the live change indicator on one flex
   row, so the indicator sits alongside (gap, not margin) and only drops below —
   with the same comfortable gap, never a naff left-indent — when there's no room. */
.grade-picker-cell {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
}

/* ── Submit-bar completeness progress ─────────────────────────────────
   The track is a recessed channel; the fill escalates left-to-right. The fill
   width is set inline (server-rendered no-JS fallback) and live-updated by Alpine
   via the object-form :style (which merges width, leaving this gradient intact).
   The fill uses the --gold-fill pair rather than the raw gold-7/9/12 scale: those
   escalate deep->bright only in DARK, where gold-12 is the lightest; in light gold-12
   is the DARKEST, so that gradient inverts (bright in the middle). --gold-fill ->
   --gold-fill-hover brightens correctly in both themes. Light track override lives
   in svp-tokens.css (bg-3 is near-invisible on cream). */
.svp-progress-track {
  height: 6px;
  width: 180px;
  border-radius: 4px;
  overflow: hidden;
  background: var(--bg-3);
}

.svp-progress-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--gold-fill), var(--gold-fill-hover));
  transition: width 240ms var(--ease-out);
}

/* ── Segmented assessment options (suitability / attendance / conduct) ─
   Radios styled as tonal chips. Selection is :checked-driven (via :has) so the
   chosen option lights up on click with no JS — pre-save UI state; persistence
   is a later concern (WS4 3.4.5). Tone comes from data-tone, mapped here to the
   status palette so the templates carry no inline colour logic. */
.seg-option {
  position: relative;
  cursor: pointer;
  padding: 8px 14px;
  border-radius: var(--r-2);
  border: 1px solid var(--border-soft);
  background: var(--bg-2);
  color: var(--text-1);
  font-size: 13px;
  font-weight: 500;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  transition: border-color 120ms, background 120ms, color 120ms;
}

.seg-option:hover {
  border-color: var(--border-strong);
}

.seg-option input {
  /* visually hidden, still focusable for a11y */
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
}

.seg-option .seg-dot {
  width: 14px;
  height: 14px;
  border-radius: 7px;
  border: 1.5px solid var(--border-strong);
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.seg-option .seg-dot::after {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 3px;
  background: transparent;
}

.seg-option:has(input:checked) .seg-dot {
  border-color: currentColor;
}

.seg-option:has(input:checked) .seg-dot::after {
  background: currentColor;
}

.seg-option:has(input:focus-visible) {
  outline: 2px solid var(--gold-10);
  outline-offset: 2px;
}

.seg-option:has(input:disabled) {
  cursor: not-allowed;
  opacity: 0.85;
}

/* Per-tone selected colours — currentColor then flows into the radio dot. */
.seg-option[data-tone="ok"]:has(input:checked) {
  border-color: var(--status-ok);
  background: var(--status-ok-tint);
  color: var(--status-ok);
}

.seg-option[data-tone="warn"]:has(input:checked) {
  border-color: var(--status-warn);
  background: var(--status-warn-tint);
  color: var(--status-warn);
}

.seg-option[data-tone="info"]:has(input:checked) {
  border-color: var(--status-info);
  background: var(--status-info-tint);
  color: var(--status-info);
}

.seg-option[data-tone="neutral"]:has(input:checked) {
  border-color: var(--text-2);
  background: var(--bg-3);
  color: var(--text-2);
}

.seg-option[data-tone="danger"]:has(input:checked) {
  border-color: var(--status-danger);
  background: var(--status-danger-tint);
  color: var(--status-danger);
}

/* The follow-up note: always in the DOM, hidden until a note-revealing option
   is checked — so "more info" appears client-side on click, no server. */
.seg-note {
  display: none;
  margin-top: 10px;
  min-height: 60px;
}

.seg-field:has(input[data-reveals-note]:checked) .seg-note {
  display: block;
}

/* Safeguarding panel: a quiet, always-visible surround that lights up subtle-red
   only when a concern is selected — a backlight reflecting the chosen option,
   so "no concern / unreviewed" never wears the alarm colour. */
.svp-safeguarding-panel {
  padding: 16px;
  border-radius: var(--r-3);
  border: 1px solid var(--border-soft);
  background: var(--bg-2);
  transition: background 180ms, border-color 180ms;
}

.svp-safeguarding-panel:has(input[name="safeguarding_concern"][value="yes"]:checked) {
  border-color: var(--status-danger-tint);
  background: linear-gradient(180deg, var(--tint-danger-soft), transparent);
}

/* ── HTMX in-flight indicator ──────────────────────────────────────── */
.htmx-indicator {
  opacity: 0;
  transition: opacity 160ms;
}

.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  opacity: 1;
}

[hx-post],
[hx-get],
[hx-patch],
[hx-delete] {
  position: relative;
}

[hx-post].htmx-request,
[hx-get].htmx-request,
[hx-patch].htmx-request {
  /* visible cue while the server is responding */
  cursor: wait;
}

/* ── CMW loader — Chevron Mexican Wave (working / loading indicator) ────
   Five chevrons ripple L→R with a hop + flare, animating their OWN width so
   a pill/button never jumps. Pure CSS: loop, enter wave, leave wave, glide.
   Spec: docs/ux-migration/cmw-loader. Reconciled to SVP's token system —
   the doc's screenshot-sampled #ECBF54 is wired to --gold-fill (bright in both, adapts
   dark/light) and pill chrome uses our surface/border tokens. The labelled-
   pill / on-gold-button / .cmw-label variants from the doc are intentionally
   omitted (we only use the bare pill); add them from the doc if ever needed. */
.cmw {
  --cmw-gold: var(--gold-fill);
  --cmw-flare: #FFF4D6;
  --cmw-glow-peak: color-mix(in srgb, var(--cmw-flare) 80%, transparent);
  /* 10% hop-peak halo (transparent on light — see below) */
  --cmw-glow-settle: color-mix(in srgb, var(--cmw-gold) 40%, transparent);
  /* 28% settle halo */
  position: relative;
  display: inline-block;
  width: 112px;
  height: 30px;
  vertical-align: middle;
  transition: width .3s cubic-bezier(.4, 0, .2, 1), margin .3s cubic-bezier(.4, 0, .2, 1);
}

.cmw .cmw-track {
  position: absolute;
  top: 50%;
  left: 10%;
  right: 10%;
  height: 1.5px;
  transform: translateY(-50%);
  background: color-mix(in srgb, var(--cmw-gold) 16%, transparent);
  transition: opacity .2s ease;
}

.cmw .cmw-cv {
  position: absolute;
  top: 50%;
  left: calc(10% + var(--i) * 20%);
  display: grid;
  place-items: center;
  color: color-mix(in srgb, var(--cmw-gold) 90%, transparent);
  transform: translate(-50%, -50%) scale(.94);
  animation: cmw-hop 1.8s ease-in-out infinite calc(var(--i) * .12s);
  opacity: 1;
  transition: opacity .26s ease;
  transition-delay: calc(.30s + var(--i) * .06s);
}

.cmw .cmw-cv svg {
  width: 18px;
  height: 18px;
  display: block;
  overflow: visible;
  transform: translateX(-1px);
}

.cmw .cmw-pulse {
  position: absolute;
  top: 50%;
  width: 28px;
  height: 14px;
  transform: translate(-50%, -50%);
  border-radius: 8px;
  mix-blend-mode: screen;
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(closest-side,
      color-mix(in srgb, var(--cmw-flare) 55%, transparent),
      color-mix(in srgb, var(--cmw-gold) 16%, transparent) 55%, transparent);
  animation: cmw-pulse 1.8s linear infinite;
}

@keyframes cmw-hop {

  0%,
  62%,
  100% {
    color: color-mix(in srgb, var(--cmw-gold) 90%, transparent);
    transform: translate(-50%, -50%) scale(.94);
    filter: none;
  }

  10% {
    color: var(--cmw-flare);
    transform: translate(-50%, calc(-50% - 8px)) scale(1.16);
    filter: drop-shadow(0 0 8px var(--cmw-glow-peak));
  }

  28% {
    color: var(--cmw-gold);
    transform: translate(-50%, -50%) scale(1);
    filter: drop-shadow(0 0 3px var(--cmw-glow-settle));
  }
}

@keyframes cmw-pulse {
  0% {
    left: 2%;
    opacity: 0;
  }

  7% {
    opacity: .65;
  }

  10% {
    left: 10%;
  }

  36.7% {
    left: 90%;
  }

  44% {
    left: 98%;
    opacity: .65;
  }

  52% {
    opacity: 0;
  }

  100% {
    left: 98%;
    opacity: 0;
  }
}

/* light mode: the hop peak brightens to the vivid --gold-fill-hover, NOT the darkened
   gold-12 (which read muddy-brown on cream — and with the base now the bright --gold-fill,
   a dark peak looks inverted). On cream the peak pops by saturation + scale + the pulse,
   not by out-brightening a light surface. */
[data-theme="light"] .cmw {
  --cmw-flare: var(--gold-fill-hover);
  /* Kill the hop drop-shadow halos on cream: a gold glow DARKER than the white card
     reads as a muddy shadow ring, not a glow (you can't out-bright a light surface —
     colour-token doctrine rule 2). The peak pops via colour + scale(1.16) + the
     .cmw-pulse radial instead. */
  --cmw-glow-peak: transparent;
  --cmw-glow-settle: transparent;
}

/* The travelling pulse uses mix-blend-mode:screen, which LIGHTENS — great on navy (it
   lifts the gold off it) but on cream it washes the pulse to a colourless near-white
   smudge: the murky "glow" behind the chevrons. On light, drop to normal blend so the
   gold radial deposits as an actual soft-gold pulse. (Screen stays in dark, its home.) */
[data-theme="light"] .cmw .cmw-pulse {
  mix-blend-mode: normal;
}

/* collapsible — width animates so inline containers never jump.
   .is-collapsed = hidden. Enter: width opens, THEN chevrons wave in.
   Leave: chevrons wave out, THEN width closes (delayed .46s ≥ wave length). */
.cmw.is-collapsed {
  width: 0;
  transition: width .3s cubic-bezier(.4, 0, .2, 1) .46s, margin .3s cubic-bezier(.4, 0, .2, 1) .46s;
}

.cmw.is-collapsed .cmw-cv {
  opacity: 0;
  transition-delay: calc(var(--i) * .06s);
}

.cmw.is-collapsed .cmw-track {
  opacity: 0;
}

.cmw.is-collapsed .cmw-pulse {
  display: none;
}

/* HTMX hook: rest collapsed, expand only while a request is in flight */
.htmx-request.cmw.is-collapsed,
.htmx-request .cmw.is-collapsed {
  width: 112px;
  transition: width .3s cubic-bezier(.4, 0, .2, 1);
}

.htmx-request.cmw.is-collapsed .cmw-cv,
.htmx-request .cmw.is-collapsed .cmw-cv {
  opacity: 1;
  transition-delay: calc(.30s + var(--i) * .06s);
}

.htmx-request.cmw.is-collapsed .cmw-track,
.htmx-request .cmw.is-collapsed .cmw-track {
  opacity: 1;
}

.htmx-request.cmw.is-collapsed .cmw-pulse,
.htmx-request .cmw.is-collapsed .cmw-pulse {
  display: block;
}

/* list-refresh overlay — a centred CMW pill over a dimmed list while it updates.
   Feedback sits on the content that's actually changing (one focal point), not
   beside the toolbar where two loaders read oddly on narrow widths. svp.js
   toggles .is-active (hx-preserve keeps the element across the swap, so the dim
   + wave ease OUT over the fresh list). Summon/dismiss choreography mirrors the
   full-page nav loader: dim in immediately + chevrons ripple in; on dismiss the
   chevrons ripple out, then the dim holds briefly before clearing. */
/* swap-height animation state (set by svp.js while easing between heights):
   clip overflow via a class, never inline, so a target's own inline overflow
   stays intact when the class is removed. */
.js-animating-height {
  overflow: hidden;
}

.svp-list-region {
  position: relative;
}

.svp-list-loader {
  position: absolute;
  inset: 0;
  z-index: 5;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding-top: 24px;
  background: color-mix(in srgb, var(--bg-1) 52%, transparent);
  opacity: 0;
  pointer-events: none;
  /* DISMISS: hold the dim while the chevrons ripple away, then clear. */
  transition: opacity .26s var(--ease-out) .16s;
}

.svp-list-loader.is-active {
  opacity: 1;
  /* SUMMON: the dim forms immediately; the chevrons ride in after. */
  transition: opacity .2s var(--ease-out);
}

/* chevron summon/dismiss — staggered opacity rides on top of the hop loop
   (lifted from the nav loader). Reduced motion neutralises the stagger via the
   global `.cmw .cmw-cv { transition: none !important }` rule. */
.svp-list-loader .cmw-cv {
  opacity: 0;
  transition: opacity .18s ease;
  transition-delay: calc(var(--i) * .045s);
  /* dismiss: --i:0 leaves first */
}

.svp-list-loader.is-active .cmw-cv {
  opacity: 1;
  transition-delay: calc(.12s + var(--i) * .05s);
  /* summon: ride in behind the dim */
}

/* opaque pill so the wave reads cleanly against the dimmed list (translucent
   looked messy over the rows), lifted with a soft shadow. */
.svp-list-loader .cmw-pill {
  background: var(--bg-2);
  border-color: var(--border-soft);
  box-shadow: 0 6px 20px color-mix(in srgb, var(--bg-0) 55%, transparent);
}

/* Clear-search affordance — a small inline × inside a search input, shown ONLY
   while the input has text (CSS-only, via :placeholder-shown on the sibling input,
   so the input must carry a placeholder). svp.js handles the click via the
   data-clears hook. Reusable across any search box. */
.svp-search-clear {
  position: absolute;
  right: 6px;
  top: 50%;
  transform: translateY(-50%);
  display: none;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--text-4);
  border-radius: var(--r-1);
  cursor: pointer;
}

.svp-search-clear:hover {
  color: var(--text-1);
  background: var(--bg-3);
}

.input:not(:placeholder-shown)~.svp-search-clear {
  display: inline-flex;
}

/* pill surround — bare (label-less). Spacing rides on the loader so it
   collapses cleanly; the whole pill fades via .is-hidden (Alpine toggle). */
.cmw-pill {
  display: inline-flex;
  align-items: center;
  gap: 0;
  padding: 9px 16px;
  border-radius: 999px;
  border: 1px solid var(--border-soft);
  background: var(--surface-hover);
  transition: opacity .25s ease;
}

.cmw-pill.is-hidden {
  opacity: 0;
  pointer-events: none;
}

.cmw-pill--bare .cmw,
.cmw-pill--bare .cmw.is-collapsed {
  margin-left: 0;
}

/* accessibility — reduced motion gets a calm opacity pulse, no hop */
@media (prefers-reduced-motion: reduce) {

  .cmw,
  .cmw .cmw-cv,
  .cmw .cmw-track {
    transition: none !important;
  }

  .cmw .cmw-pulse {
    display: none;
  }

  .cmw .cmw-cv {
    animation: cmw-calm 1.6s ease-in-out infinite calc(var(--i)*.16s) !important;
  }

  .cmw.is-collapsed .cmw-cv {
    animation: none !important;
    opacity: 0 !important;
  }

  @keyframes cmw-calm {

    0%,
    100% {
      opacity: .35;
    }

    50% {
      opacity: 1;
    }
  }
}

/* ── Full-page navigation loader ───────────────────────────────────────
   Covers the page during a boosted / full-page nav: the CMW wave + a rotating
   message over a low navy scrim (var(--scrim)) that fades in and out. svp.js
   toggles .is-active after a short delay so quick navs never flash it. */
.svp-nav-loader {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--scrim);
  opacity: 0;
  pointer-events: none;
  /* DEPART: the scrim holds briefly (delay) while the chevrons ripple away,
     THEN fades — same idea as the CMW collapsed-state's delayed close. */
  transition: opacity .24s var(--ease-out) .16s;
}

.svp-nav-loader.is-active {
  opacity: 1;
  pointer-events: auto;
  /* SUMMON: the scrim dims in QUICKLY (.2s) so feedback lands first; the wave +
     message ride in on it. No backdrop blur — the focal-shift it caused read as
     disorienting on fast navs, so the low scrim carries the focus on its own. */
  transition: opacity .2s var(--ease-out);
}

/* Chevron summon / depart — the CMW staged wave, lifted off the .is-collapsed
   width-glide (that's for inline pills) and driven by the overlay's .is-active
   state instead. Chevrons RIPPLE IN left-to-right onto the settling scrim, and
   ripple OUT in the same order as it leaves; the hop loop (cmw-hop) rides on
   top of the opacity, untouched. Reduced motion neutralises the stagger via the
   global `.cmw .cmw-cv { transition: none !important }` rule below. */
.svp-nav-loader .cmw-cv {
  opacity: 0;
  transition: opacity .18s ease;
  transition-delay: calc(var(--i) * .045s);
  /* depart: --i:0 leaves first */
}

.svp-nav-loader.is-active .cmw-cv {
  opacity: 1;
  transition-delay: calc(.12s + var(--i) * .05s);
  /* summon: ride in on the scrim */
}

.svp-nav-loader-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  padding: 26px 40px;
  background: color-mix(in srgb, var(--surface-card) 72%, transparent);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-4);
  box-shadow: var(--shadow-pop);
  /* The blur lives HERE now, not the full page: a frosted panel just behind the
     wave + phrase keeps the text crisp and readable, WITHOUT the disorienting
     full-screen focal-shift the old page-wide backdrop blur caused. */
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}

/* The wave is the instant feedback; the MESSAGE holds back a beat (transition-delay
   below) so a fast nav clears the loader before the words ever paint — no flitter on
   a quick hop. It leaves promptly on depart. min-height reserves its line so the wave
   never jumps when the text mounts. */
.svp-nav-loader-msg {
  font-size: 13px;
  color: var(--text-2);
  min-height: 1em;
  opacity: 0;
  transition: opacity .18s ease;
}

.svp-nav-loader.is-active .svp-nav-loader-msg {
  opacity: 1;
  transition-delay: .5s;
}

/* reduced motion: a calm scrim fade only. The message appears INSTANTLY rather
   than fading (it's readable content, not ambient chrome — instant beats faded
   for the motion-sensitive user); both the base fade and the summon delay are
   zeroed so neither survives. */
@media (prefers-reduced-motion: reduce) {
  .svp-nav-loader {
    transition: opacity .2s ease;
  }

  .svp-nav-loader-msg,
  .svp-nav-loader.is-active .svp-nav-loader-msg {
    transition: none;
    transition-delay: 0s;
  }
}

/* ── Slide-over drawer (admin edit) ──────────────────────────────────────────
   A right-side panel for deliberate, explicit-save editing (status categories
   first). Opening/closing is Alpine-driven client state — instant, no server
   round-trip (the client-side-UI-state doctrine); HTMX fills the body, the
   server persists. The panel stays in the DOM and slides on `transform`; closed
   it is visibility:hidden + pointer-events:none so it leaves the tab order and
   never swallows clicks. The backdrop fades via Alpine x-transition. */
/* z-index 90/91: the drawer is a full-screen modal, so it must sit ABOVE the
   mobile chrome — the mobile top-bar (flex-item z-index 60) and the off-canvas
   sidebar (80) — while staying below the nav-loader (300) + toasts (400). On
   desktop there's no mobile bar, so this only matters on narrow widths. */
.svp-drawer-backdrop {
  position: fixed;
  inset: 0;
  z-index: 90;
  background: var(--bg-overlay);
}

.svp-drawer {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 91;
  width: min(480px, 100%);
  background: var(--surface-card);
  border-left: 1px solid var(--border-soft);
  box-shadow: var(--shadow-pop);
  display: flex;
  flex-direction: column;
  transform: translateX(100%);
  transition: transform .28s var(--ease-out);
  visibility: hidden;
  pointer-events: none;
}

.svp-drawer.is-open {
  transform: translateX(0);
  visibility: visible;
  pointer-events: auto;
}

@media (prefers-reduced-motion: reduce) {
  .svp-drawer {
    transition: none;
  }
}

/* Modal dialog — a native <dialog>, centred by the UA over a scrimmed, inert
   background (opened via [data-dialog-open] in svp.js). Distinct from .svp-drawer
   (a side slide-over): a centred modal is the right shape for a short, self-contained
   disclosure like the footer data-retention note. padding:0 so the only clicks that
   land on the dialog element itself are backdrop clicks (svp.js closes on those); the
   inner header/body/foot sections carry their own padding. */
.svp-modal {
  padding: 0;
  border: 1px solid var(--border-soft);
  border-radius: var(--r-3);
  background: var(--surface-card);
  color: var(--text-1);
  box-shadow: var(--shadow-pop);
  width: min(460px, calc(100vw - 32px));
  max-height: min(80vh, 640px);
  overflow: hidden;
}

/* Scoped to [open] so it doesn't override the UA's display:none on a closed dialog.
   The column layout is what lets the body scroll: a plain block <dialog> gives its
   children no height constraint, so an inner overflow:auto never engages and the
   modal just clips past max-height (the same min-height:0 flex gotcha _base documents
   for <main>). Head/foot hold their size; the body takes the slack and scrolls. */
.svp-modal[open] {
  display: flex;
  flex-direction: column;
}

.svp-modal::backdrop {
  background: var(--bg-overlay);
}

.svp-modal-head,
.svp-modal-foot {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  gap: 12px;
  padding: 15px 20px;
}

.svp-modal-head {
  justify-content: space-between;
  border-bottom: 1px solid var(--border-faint);
}

.svp-modal-foot {
  justify-content: flex-end;
  border-top: 1px solid var(--border-faint);
}

.svp-modal-title {
  font-size: 15px;
  font-weight: 600;
  line-height: 1.2;
  margin: 0;
}

.svp-modal-body {
  flex: 1;
  min-height: 0;
  padding: 18px 20px;
  overflow-y: auto;
}

.svp-retention-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.svp-retention-item {
  padding-bottom: 14px;
  border-bottom: 1px solid var(--border-faint);
}

.svp-retention-item:last-child {
  padding-bottom: 0;
  border-bottom: none;
}

.svp-retention-days {
  flex-shrink: 0;
  white-space: nowrap;
  font-size: 12px;
  color: var(--gold-10);
}

/* Sidebar footer disclosure link — small, muted, quiet-hover; deliberately not a
   nav item (it opens a modal, it doesn't navigate). Resets the <button> chrome so it
   reads as a text link in the sidebar's footer stack. */
.svp-footer-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-top: 10px;
  padding: 2px 0;
  border: none;
  background: none;
  cursor: pointer;
  font: inherit;
  font-size: 11px;
  color: var(--text-4);
  transition: color .15s var(--ease-out);
}

.svp-footer-link:hover {
  color: var(--text-2);
}

/* In-drawer outcome banner — a role change / refusal speaks inside the drawer (it
   stays open). Tone-driven by a class modifier, matching the .pill-{tone} house
   pattern (tones in svp-tokens.css), not an inline colour map. */
.svp-drawer-notice {
  border-left: 3px solid var(--status-neutral);
  background: var(--bg-2);
  padding: 9px 12px;
  border-radius: 4px;
  font-size: 12.5px;
}

.svp-drawer-notice--success {
  border-left-color: var(--status-ok-fill);
  background: var(--status-ok-tint);
}

.svp-drawer-notice--info {
  border-left-color: var(--status-info-fill);
  background: var(--status-info-tint);
}

.svp-drawer-notice--warning {
  border-left-color: var(--status-warn-fill);
  background: var(--status-warn-tint);
}

/* Submit-button busy label — a button carrying a .btn-rest + .btn-busy pair shows
   the rest label normally and swaps to the busy one ("Save changes" → "Saving")
   while its owning form's request is in flight (the form gets .htmx-request, the
   button is a descendant). Reusable for any HTMX submit button. */
.btn-busy {
  display: none;
}

.htmx-request .btn-rest {
  display: none;
}

.htmx-request .btn-busy {
  display: inline;
}

/* In-button busy face for Alpine plain-POST submit buttons (see the submit_face macro).
   inline-flex so the spinning glyph + relabel keep the .btn's centred icon+label
   alignment inside the single x-show span; a class (not inline display) so x-show's
   own display toggle reveals it cleanly rather than being wiped. */
.btn-face {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

/* Static (no-Alpine) busy-face swap: the magic-link interstitial toggles `.is-busy`
   on the button via its inline submit-guard (it loads no Alpine, so no x-show), and
   CSS does the idle<->busy swap. Same two .btn-face spans + spinning `refresh` glyph
   as the Alpine path; only the driver differs. See the submit_face macro (static). */
.btn .btn-face--busy {
  display: none;
}

.btn.is-busy .btn-face--idle {
  display: none;
}

.btn.is-busy .btn-face--busy {
  display: inline-flex;
}

/* ── Sidebar: cope with short / landscape viewports ──────────────────────────
   The persistent sidebar (>1024px; below that it's the off-canvas drawer) is a
   height:100vh flex column inside a height:100vh/overflow:hidden shell. Without
   this, a wide-but-short viewport overflows the panel's bottom into the clipped
   zone and the footer (context switcher, last-login, the data-retention link)
   vanishes with no way to scroll to it.

   Fix: the NAV is the scroll region — its scrollbar is the visible "there's more"
   cue — and the footer stays pinned to the bottom via the nav's flex:1, which
   shrinks (min-height:0) to give the footer its space and scroll its own
   overflow. On a tall viewport it doesn't scroll (the flex:1 nav just fills the
   slack), so the desktop layout is unchanged.

   Deliberately NOT overflow-y on .svp-sidebar itself: per the CSS overflow spec,
   overflow-y:auto forces overflow-x from visible to auto, which would clip the
   footer buttons' centred .svp-tip tooltips (logout / context-switch) at the
   240px edge instead of letting them bleed over the main content. The nav-scroll
   covers every realistic viewport; the only case it doesn't is a >1024px-wide
   window shorter than the fixed chrome + footer (~an absurd 1024x340) — not worth
   a tooltip regression (trim past the peak). */
.svp-sidebar-nav {
  min-height: 0;
  overflow-y: auto;
}

/* ── Sidebar nav item ────────────────────────────────────────────────────────
   Active state is class-driven (.is-active) rather than inline, so the highlight
   can move CLIENT-SIDE after a boosted main-only nav swap — the sidebar lives
   outside <main> and isn't re-rendered on navigation, so svp.js re-syncs
   .is-active to the current URL (see syncActiveNav). Visuals match what was
   previously rendered inline per-item. */
.svp-nav-item {
  padding: 8px 10px;
  display: flex;
  align-items: center;
  gap: 10px;
  background: transparent;
  color: var(--text-2);
  border-radius: var(--r-2);
  font-size: 13px;
  font-weight: 500;
  text-decoration: none;
  position: relative;
  transition: background 120ms, color 120ms, translate 120ms;
  transition-timing-function: ease-out;
}

.svp-nav-item:hover {
  background: var(--row-hover);
  color: var(--text-1);
  translate: 4px 0;
}

.svp-nav-item.is-active {
  background: var(--bg-2);
  color: var(--text-1);
}

/* The gold spine on the active item (was a conditional <span>, now ::before). */
.svp-nav-item.is-active::before {
  content: "";
  position: absolute;
  left: -10px;
  top: 8px;
  bottom: 8px;
  width: 2px;
  background: var(--gold-fill);
  border-radius: 0 2px 2px 0;
  transition: left 120ms;
  transition-timing-function: ease-out;
}

.svp-nav-item.is-active:hover::before {
  left: -14px;
}

.svp-nav-item .nav-badge {
  font-size: 11px;
  font-weight: 600;
  padding: 1px 6px;
  border-radius: 10px;
  background: var(--bg-3);
  color: var(--text-2);
}

.svp-nav-item.is-active .nav-badge {
  background: var(--gold-tint-2);
  color: var(--gold-10);
}

/* The sidebar identity block doubles as the link to the user's own account &
   security page. Padding + equal negative margin give it a hover hit-area and
   affordance without shifting the chip's layout. The hover uses the gold tint —
   the app's interaction accent — so it reads clearly as clickable without an
   on-screen label (an infrequent user finds their account by the gold cue). */
.svp-account-chip {
  text-decoration: none;
  color: inherit;
  padding: 6px;
  margin: -6px;
  border-radius: var(--r-2);
  transition: background 0.12s ease;
}

.svp-account-chip:hover {
  background: var(--gold-tint);
}

/* ─────────────── maintenance banner (sign-in disabled — admin tell) ───────────────
   Sits at the top of the authed content area (a non-shrinking flex item in <main>,
   above the topbar) so the System Admin who can still sign in can't forget the portal
   is sealed for everyone else. Loud danger fill, white text, non-dismissible — it's
   live system state, not a notice. Carries a one-click re-enable (white button →
   gold on hover, the app's interaction accent). */
.svp-maintenance-banner {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding: 12px 28px;
  background: var(--status-danger);
  color: #fff;
  border-bottom: 1px solid rgba(0, 0, 0, 0.25);
  box-shadow: var(--shadow-2);
}

.svp-maintenance-banner__text {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
  line-height: 1.45;
}

.svp-maintenance-banner__icon {
  flex-shrink: 0;
}

.svp-maintenance-banner__meta {
  display: block;
  font-size: 12.5px;
  opacity: 0.92;
}

.svp-maintenance-banner__btn {
  flex-shrink: 0;
  background: #fff;
  color: var(--status-danger);
  border-color: #fff;
}

.svp-maintenance-banner__btn:hover {
  background: var(--gold-fill);
  color: var(--gold-on-dark);
  border-color: var(--gold-10);
}

/* Active sort tab in a [data-sortable] ranking (svp.js toggles .is-active on the
   pressed [data-sort-by] button). The muted->bright shift alone reads weakly at a
   glance, so the active ranking gets a clear gold accent — which ranking is on
   should be legible without reading, per the GLANCE doctrine. */
[data-sortable] [data-sort-by].is-active {
  color: var(--gold-10);
  font-weight: 600;
}

/* ─────────────── Marina Tech Ltd maker's mark ───────────────
   Three skewed parallelograms in a descending teal gradient. Ported verbatim
   from CUED (templates/layout.html.twig), the proven mark that reads the same
   on light and dark. All geometry derives from --mt-h (default 1em, so it
   tracks the surrounding text size); the same markup renders crisp from ~8px
   to 80px without re-doing the geometry. Brand teals are HARD-CODED (not theme
   tokens): this is Marina Tech's own brand identity, not part of SVP's palette,
   so it must read identically in either theme. Rendered via the marina_mark()
   macro (_macros.html.twig) — currently the login provenance strip
   (layout/auth.html.twig). */
.mt-logo {
  --mt-h: 1em;
  display: inline-flex;
  align-items: center;
  height: var(--mt-h);
  line-height: 1;
}

.mt-slat {
  display: inline-block;
  height: var(--mt-h);
  width: calc(var(--mt-h) * 16 / 11);
  transform: skewX(-5deg) skewY(-47deg);
}

.mt-slat-2,
.mt-slat-3 {
  margin-left: calc(var(--mt-h) * -5 / 11);
}

.mt-slat-1 {
  background: linear-gradient(135deg, #7dd3d8, #5bc4c9);
}

.mt-slat-2 {
  background: linear-gradient(135deg, #5bc4c9, #3aafb4);
}

.mt-slat-3 {
  background: linear-gradient(135deg, #3aafb4, #2d9ea0);
}

/* ── Sign-in entrance (first-landing glow-up) ──────────────────────────
   A subtle staggered rise-and-fade for the pre-auth composition on load:
   elements settle up ~9px into place while fading in, so the first landing
   reads as considered and reassuring rather than snapping into existence.
   Cosmetic only — the RESTING state is the natural one (no base opacity or
   transform override), so if the animation never runs (unsupported, or
   prefers-reduced-motion) everything shows immediately at full opacity, never
   stuck hidden. Per-element stagger via --enter-delay. Honours
   prefers-reduced-motion: motion off, instant final state. */
@keyframes svp-enter-rise {
  from {
    opacity: 0;
    transform: translateY(9px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

.svp-enter {
  animation: svp-enter-rise 0.55s cubic-bezier(0.22, 0.7, 0.3, 1) both;
  animation-delay: var(--enter-delay, 0s);
}

@media (prefers-reduced-motion: reduce) {
  .svp-enter {
    animation: none;
  }
}

/* ── Cohort rail — live "Validate" station halo ────────────────────────
   The school's own station glows with a two-band halo: a bright, pulsing inner
   ring plus a steadier, dimmer outer ring — hard-edged, comic "defined glow"
   (spread-only, no blur). Applied via .svp-cohort-live, which the template
   gates on "it's our station AND has ≥1 to act on" — an empty status shouldn't
   draw the eye. The halo BREATHES with a trailing echo: the inner ring pulses
   out quickly and settles back over a long relax, while the dimmer outer ring
   swells a beat behind it — cresting later and lagging on the way in — so the
   glow ripples outward rather than moving as one. That needs independent timing
   per ring, so the bright ring is the dot's own box-shadow and the dim ring is
   a concentric ::after with its own animation + curves. Values eye-tuned
   in-browser. Honours prefers-reduced-motion: the halo stays (a meaningful cue,
   not decoration) — it just stops breathing. */
@keyframes svp-halo-inner {

  0%,
  100% {
    box-shadow: 0 0 0 3px var(--gold-tint-2);
  }

  20% {
    box-shadow: 0 0 0 6px var(--gold-tint-3);
  }
}

@keyframes svp-halo-outer {
  0% {
    box-shadow: 0 0 0 6px var(--gold-tint-2);
    animation-timing-function: ease-in-out;
  }

  /* swells slower than the inner's quick pulse, cresting later — trails outward */
  50% {
    box-shadow: 0 0 0 8px var(--gold-tint-2);
    animation-timing-function: cubic-bezier(0.7, 0, 0.85, 0.35);
  }

  /* holds wide then eases back in — the lagging tail of the echo */
  100% {
    box-shadow: 0 0 0 6px var(--gold-tint-2);
  }
}

.svp-cohort-live {
  box-shadow: 0 0 0 3px var(--gold-tint-2);
  animation: svp-halo-inner 2.8s ease-in-out infinite;
}

.svp-cohort-live::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  z-index: -1;
  /* keep the pseudo within the dot's own stacking context, behind its opaque face. The halo tints all share one RGB (only alpha differs), so the two rings composite order-independently — the two-band look holds whichever paints on top. */
  box-shadow: 0 0 0 6px var(--gold-tint-2);
  animation: svp-halo-outer 2.8s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {

  .svp-cohort-live,
  .svp-cohort-live::after {
    animation: none;
  }
}