/* =====================================================================
   PulsarOps AI - motion-c.css
   Batch C. Seven teaching visualizations, seven new mechanics.

   Loads AFTER site.css and motion.css. Reads the same contextual tokens
   site.css sets on .section--dark / .section--light, and reuses the
   .viz / .viz__stage / .viz__cap / .viz__label shell from motion.css.
   It never hardcodes a page colour.

   This sheet overrides nothing in motion.css. Every selector below is
   namespaced under a viz-zoom / viz-reorder / viz-heat / viz-unfold /
   viz-wipe / viz-handoff / viz-gauge block, and every keyframe name is
   prefixed viz-c-, so the two sheets cannot collide.

   Build only from _MOTION-LIBRARY-C.md.

   THE RULES THIS SHEET FOLLOWS
   - Every loop completes in 4 seconds. Three or four beats, about a
     second each, and the hold is 0.6s to 1s, never longer.
   - Staggers are 0.06s to 0.18s. A five item stagger costs 0.7s, not 3s.
   - Loops move transform and opacity only. The single exception is the
     wipe (21), which animates clip-path inset on one element, because a
     wipe is not expressible as a transform without counter-scaling live
     type. clip-path inset is paint only, it triggers no layout, and it
     is confined to that one rule.
   - Every component carries a heartbeat on its own short clock, so no
     two frames sampled 1.5s apart are identical.
   - Reduced motion resolves to the frame that carries the meaning.

   Contents
   17  ZOOM WINDOW    timeline compression
   18  REORDER        sorting
   19  HEAT GRID      intensity map
   20  UNFOLD         expand to reveal
   21  WIPE COMPARE   split screen wipe
   22  HANDOFF        relay between holders
   23  GAUGE          arc fill and settle
   24  Shared heartbeat
   25  Reduced motion end states
   ===================================================================== */


/* =====================================================================
   17  ZOOM WINDOW - timeline compression
   Teaching: the fault window is minutes inside a day of data.

   Two registers stacked. The upper one is a whole day of ticks with a
   selection bracket that narrows onto one region. The lower one is that
   same region expanded to the full width of the stage, so the pixels
   that held twenty four hours now hold a few minutes. The expansion
   starts at exactly the bracket's own final width, which is what makes
   the two registers read as one dataset at two magnifications.

   Beats: ticks arrive (0 - 0.6s), bracket narrows (0.6 - 1.8s), lens
   expands (1.1 - 1.8s), hold with the window named (1.8 - 3.7s), reset.
   ===================================================================== */

.viz-zoom {
  --viz-zoom-dur: 7s;
  /* The bracket used to sit at a hand-picked 58% while the ticks were laid
     out by flex space-between inside 0.5rem of padding. Those are two
     different coordinate systems, so the window landed beside the hot
     ticks instead of around them. The ticks are now a 48 column grid across
     the full box, so both numbers below are exact fractions of that grid
     and the bracket cannot drift. Hot ticks are columns 28 and 29. */
  --viz-zoom-at: 56.25%;   /* 27 / 48 */
  --viz-zoom-w: 0.04167;   /*  2 / 48 */
}

.viz-zoom__stage { display: grid; gap: var(--sp-4); }

.viz-zoom__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin: 0;
}

/* ---- Upper register: the whole day ---- */

.viz-zoom__day {
  position: relative;
  height: 3rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  overflow: hidden;
}

.viz-zoom__ticks {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-columns: repeat(48, 1fr);
  align-items: end;
  /* No horizontal padding: the grid has to span the same box the bracket
     is positioned against, or the two disagree by exactly that padding. */
  padding: 0.5rem 0;
}

/* The day is drawn as fine hairlines and the lens as broad bars. That
   contrast in tick weight is what makes the lower register read as a
   magnification of the upper one rather than a second chart. */
.viz-zoom__ticks i {
  width: 2px;
  justify-self: center;
  height: 45%;
  border-radius: 1px;
  background: var(--viz-dim);
  transform-origin: bottom;
  animation: viz-c-tick var(--viz-zoom-dur) var(--ease-out) infinite;
  /* 48 hairlines at 8ms is a 0.38s sweep, inside the first beat. */
  animation-delay: calc(var(--i, 0) * 0.014s);
}

@keyframes viz-c-tick {
  0%       { opacity: 0; transform: scaleY(0.2); }
  14%, 92% { opacity: 0.28; transform: scaleY(1); }
  100%     { opacity: 0; transform: scaleY(0.2); }
}

/* The hours that carry the fault stand taller and warm. They are what
   the bracket is about to land on. */
.viz-zoom__ticks i.is-hot {
  width: 3px;
  height: 82%;
  background: var(--warn);
  animation-name: viz-c-tick-hot;
}

@keyframes viz-c-tick-hot {
  0%       { opacity: 0; transform: scaleY(0.2); }
  14%      { opacity: 0.5; transform: scaleY(1); }
  30%, 92% { opacity: 0.95; transform: scaleY(1); }
  100%     { opacity: 0; transform: scaleY(0.2); }
}

/* The bracket. Starts the width of the whole day, ends the width of the
   fault window. translateX percentages resolve against the element's own
   untransformed border box, so the 58% is 58% of the day whatever the
   scaleX beside it is doing. */
.viz-zoom__window {
  position: absolute;
  inset: 0;
  border-radius: var(--r-xs);
  border: 1.5px solid var(--viz-accent);
  background: var(--accent-soft);
  transform-origin: left center;
  will-change: transform, opacity;
  animation: viz-c-window var(--viz-zoom-dur) var(--ease-settle) infinite;
}

@keyframes viz-c-window {
  0%       { opacity: 0; transform: translateX(0) scaleX(1); }
  8%, 26%  { opacity: 0.55; transform: translateX(0) scaleX(1); }
  52%, 92% { opacity: 1; transform: translateX(var(--viz-zoom-at)) scaleX(var(--viz-zoom-w)); }
  100%     { opacity: 0; transform: translateX(0) scaleX(1); }
}

/* ---- Lower register: that window, expanded ---- */

.viz-zoom__lens {
  position: relative;
  height: 3.75rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--viz-accent);
  background: var(--surface-2);
  overflow: hidden;
}

.viz-zoom__grow {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: flex-end;
  gap: 2px;
  padding: 0.5rem 0.5rem 1.35rem;
  transform-origin: left center;
  will-change: transform, opacity;
  animation: viz-c-grow var(--viz-zoom-dur) var(--ease-settle) infinite;
}

@keyframes viz-c-grow {
  0%, 34%  { opacity: 0; transform: scaleX(var(--viz-zoom-w)); }
  56%, 92% { opacity: 1; transform: scaleX(1); }
  100%     { opacity: 0; transform: scaleX(var(--viz-zoom-w)); }
}

.viz-zoom__grow i {
  flex: 1 1 0;
  min-width: 0;
  height: 38%;
  border-radius: 1px;
  background: var(--viz-dim);
  opacity: 0.35;
}

.viz-zoom__grow i.is-hot { height: 100%; background: var(--warn); opacity: 0.9; }

/* The label rides outside the scaling layer, so no type is ever
   stretched by the expansion. */
.viz-zoom__mark {
  position: absolute;
  left: 0.625rem;
  bottom: 0.375rem;
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  color: var(--viz-accent);
  white-space: nowrap;
  animation: viz-c-mark var(--viz-zoom-dur) var(--ease-out) infinite;
}

@keyframes viz-c-mark {
  0%, 58%  { opacity: 0; transform: translate3d(-0.35rem, 0, 0); }
  68%, 92% { opacity: 1; transform: translate3d(0, 0, 0); }
  100%     { opacity: 0; transform: translate3d(-0.35rem, 0, 0); }
}

.viz-zoom__scale {
  display: flex;
  justify-content: space-between;
  margin: -0.5rem 0 0;
  padding: 0;
  list-style: none;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  color: var(--fg-3);
}

@media (max-width: 640px) {
  .viz-zoom__day { height: 2.5rem; }
  .viz-zoom__lens { height: 3.25rem; }
}


/* =====================================================================
   18  REORDER - sorting
   Teaching: severity is not arrival order.

   Rows are written in the DOM in their SORTED order and start displaced
   into their ARRIVAL order. Each row's displacement is computed in CSS
   from its own --from against its own --to, so the sort really does
   converge on the written order rather than sliding a fixed distance.

   Beats: rows land in arrival order (0 - 0.4s), hold (0.4 - 1.6s), sort
   (1.6 - 2.5s), worst row marked and held (2.5 - 3.7s), reset.
   ===================================================================== */

.viz-reorder {
  --viz-reorder-dur: 3.7s;
  --viz-reorder-pitch: 2.25rem;
  --viz-reorder-step: 0.06s;
}

.viz-reorder__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin: 0 0 0.625rem;
  padding-bottom: 0.5rem;
  border-bottom: 1px solid var(--viz-line);
}

/* Two labels in one grid cell. The header states which order you are
   looking at, and it changes at the moment the rows do. */
.viz-reorder__mode { display: inline-grid; }

.viz-reorder__mode > span {
  grid-area: 1 / 1;
  animation: viz-c-mode var(--viz-reorder-dur) var(--ease-out) infinite;
}

.viz-reorder__mode > span + span { animation-name: viz-c-mode-b; }

@keyframes viz-c-mode {
  0%, 40%  { opacity: 1; transform: translate3d(0, 0, 0); }
  55%, 92% { opacity: 0; transform: translate3d(0, -0.3rem, 0); }
  100%     { opacity: 1; transform: translate3d(0, 0, 0); }
}

@keyframes viz-c-mode-b {
  0%, 40%  { opacity: 0; transform: translate3d(0, 0.3rem, 0); }
  55%, 92% { opacity: 1; transform: translate3d(0, 0, 0); }
  100%     { opacity: 0; transform: translate3d(0, 0.3rem, 0); }
}

.viz-reorder__rows {
  display: grid;
  grid-auto-rows: var(--viz-reorder-pitch);
  margin: 0;
  padding: 0;
  list-style: none;
}

.viz-reorder__row {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 7.5rem) minmax(0, 1fr) auto;
  align-items: center;
  gap: 0.5rem;
  padding: 0 0.5rem;
  border-radius: var(--r-xs);
  font-size: var(--fs-meta);
  color: var(--fg-3);
  min-width: 0;
  will-change: transform, opacity;
  animation: viz-c-sort var(--viz-reorder-dur) var(--ease-settle) infinite;
  animation-delay: calc(var(--to, 0) * var(--viz-reorder-step));
}

.viz-reorder__row .ico { color: var(--viz-dim); }

.viz-reorder__row b {
  color: var(--fg-2);
  font-weight: 600;
  font-family: var(--font-mono);
  font-size: 0.75rem;
}

.viz-reorder__row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* The displacement is (arrival index - sorted index) row pitches. */
@keyframes viz-c-sort {
  0% {
    opacity: 0;
    transform: translate3d(-0.4rem, calc((var(--from, 0) - var(--to, 0)) * var(--viz-reorder-pitch)), 0);
  }
  10%, 40% {
    opacity: 1;
    transform: translate3d(0, calc((var(--from, 0) - var(--to, 0)) * var(--viz-reorder-pitch)), 0);
  }
  62%, 92% { opacity: 1; transform: translate3d(0, 0, 0); }
  100% {
    opacity: 0;
    transform: translate3d(-0.4rem, calc((var(--from, 0) - var(--to, 0)) * var(--viz-reorder-pitch)), 0);
  }
}

/* The row that ends on top is the row the sort was for. */
.viz-reorder__row--worst::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: var(--r-xs);
  border: 1.5px solid var(--viz-accent);
  background: var(--accent-soft);
  animation: viz-c-worst var(--viz-reorder-dur) var(--ease-out) infinite;
}

@keyframes viz-c-worst {
  0%, 58%  { opacity: 0; }
  72%, 92% { opacity: 1; }
  100%     { opacity: 0; }
}

.viz-reorder__row--worst > * { position: relative; }

.viz-reorder__sev {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  padding: 0.05rem 0.35rem;
  border-radius: var(--r-xs);
  border: 1px solid var(--border-strong);
  color: var(--fg-3);
  white-space: nowrap;
}

.viz-reorder__sev--p1 { color: var(--warn); border-color: var(--warn); }

@media (max-width: 640px) {
  .viz-reorder { --viz-reorder-pitch: 2rem; }
  .viz-reorder__row { grid-template-columns: auto minmax(0, 5.75rem) minmax(0, 1fr) auto; }
}


/* =====================================================================
   19  HEAT GRID - intensity map
   Teaching: failures cluster, they rarely spread evenly.

   Thirty-two device cells, no labels. Each carries a --h intensity of 0
   to 3 and a --d ring index. The grid warms all at once, outward from
   the middle of the cluster, and its peak opacity is computed from --h.
   That makes the picture data shaped rather than sequence shaped, which
   is the opposite reading to the vendor lattice, where exactly one cell
   is lit at a time in reading order.

   Beats: cold (0 - 0.3s), bloom (0.3 - 1.7s), peak hold (1.7 - 3.0s),
   cool (3.0 - 4s). The core cell pulses on its own 2.2s clock throughout.
   ===================================================================== */

.viz-heat {
  --viz-heat-dur: 3.7s;
  --viz-heat-step: 0.06s;
  --viz-heat-cols: 16;
}

.viz-heat__stage { display: grid; gap: var(--sp-4); }

.viz-heat__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin: 0;
}

.viz-heat__grid {
  display: grid;
  grid-template-columns: repeat(var(--viz-heat-cols, 16), minmax(0, 1fr));
  gap: 0.25rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.viz-heat__cell {
  position: relative;
  aspect-ratio: 1;
  border-radius: var(--r-xs);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  min-width: 0;
}

.viz-heat__cell::after {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  background: var(--warn);
  opacity: 0;
  will-change: opacity, transform;
  animation: viz-c-heat var(--viz-heat-dur) var(--ease-in-out) infinite;
  animation-delay: calc(var(--d, 0) * var(--viz-heat-step));
}

@keyframes viz-c-heat {
  0%, 6%    { opacity: 0; transform: scale(0.88); }
  34%, 84%  { opacity: calc(var(--h, 0) * 0.28); transform: scale(1); }
  98%, 100% { opacity: 0; transform: scale(0.88); }
}

/* The hottest cell. It carries the heartbeat on its own short clock, so
   the map is never still even during the peak hold. */
.viz-heat__cell--core { border-color: var(--viz-accent); }

.viz-heat__cell--core::before {
  content: "";
  position: absolute;
  inset: -3px;
  border-radius: var(--r-xs);
  border: 1.5px solid var(--viz-accent);
  animation: viz-c-corepulse 2.2s var(--ease-out) infinite;
}

@keyframes viz-c-corepulse {
  0%        { opacity: 0.9; transform: scale(0.94); }
  70%, 100% { opacity: 0; transform: scale(1.25); }
}

.viz-heat__key {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0;
  font-size: var(--fs-micro);
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--fg-3);
}

.viz-heat__ramp { display: inline-flex; gap: 0.2rem; }

.viz-heat__ramp i {
  width: 0.9rem;
  height: 0.55rem;
  border-radius: 2px;
  background: var(--warn);
  opacity: calc(0.12 + var(--h, 0) * 0.28);
}

/* The column count never changes with the viewport. --h and --d are
   written per cell against a 16 wide grid, so reflowing to 12 columns
   would scatter the cluster the map exists to show. The cells shrink
   instead. */
@media (max-width: 640px) {
  .viz-heat__grid { gap: 0.15rem; }
  .viz-heat__cell { border-radius: 3px; }
}


/* =====================================================================
   20  UNFOLD - expand to reveal
   Teaching: one alert, opened, contains the whole chain.

   The body's height is reserved for the whole loop. What animates is a
   veil the colour of the stage, scaled from its bottom edge, so the
   chain is uncovered from the top down without one line of type being
   scaled. Nothing here animates height and nothing distorts.

   Beats: the alert alone (0 - 0.6s), unfold (0.6 - 1.6s), chain
   staggered in (0.7 - 1.9s), hold (1.9 - 3.4s), refold (3.4 - 4s).
   ===================================================================== */

.viz-unfold {
  --viz-unfold-dur: 4s;
  --viz-unfold-step: 0.12s;
}

.viz-unfold__row {
  display: grid;
  grid-template-columns: auto auto auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.625rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--viz-accent);
  background: var(--surface-2);
  font-size: var(--fs-meta);
  color: var(--fg-3);
  min-width: 0;
}

.viz-unfold__row b {
  color: var(--fg-2);
  font-weight: 600;
  font-family: var(--font-mono);
  font-size: 0.75rem;
}

.viz-unfold__row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.viz-unfold__row .ico { color: var(--warn); }

.viz-unfold__chev {
  display: inline-grid;
  place-items: center;
  color: var(--viz-accent);
  animation: viz-c-chev var(--viz-unfold-dur) var(--ease-settle) infinite;
}

.viz-unfold__chev .ico { color: var(--viz-accent); }

@keyframes viz-c-chev {
  0%, 14%  { transform: rotate(0deg); }
  40%, 86% { transform: rotate(90deg); }
  100%     { transform: rotate(0deg); }
}

.viz-unfold__body {
  position: relative;
  margin-top: 0.5rem;
  padding: 0.375rem 0 0.125rem 1.125rem;
  overflow: hidden;
}

/* The spine the chain hangs from. It grows down with the reveal. */
.viz-unfold__body::before {
  content: "";
  position: absolute;
  left: 0.4rem;
  top: 0.375rem;
  bottom: 0.75rem;
  width: 1px;
  background: var(--viz-accent);
  opacity: 0.5;
  transform-origin: top;
  animation: viz-c-spine var(--viz-unfold-dur) var(--ease-settle) infinite;
}

@keyframes viz-c-spine {
  0%, 14%  { transform: scaleY(0); }
  40%, 86% { transform: scaleY(1); }
  100%     { transform: scaleY(0); }
}

.viz-unfold__chain {
  display: grid;
  gap: 0.375rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.viz-unfold__chain li {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 0.5rem;
  font-size: var(--fs-meta);
  color: var(--fg-3);
  min-width: 0;
  animation: viz-c-chain var(--viz-unfold-dur) var(--ease-out) infinite;
  animation-delay: calc(var(--i, 0) * var(--viz-unfold-step));
}

.viz-unfold__chain li .ico { color: var(--viz-accent); }
.viz-unfold__chain li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

@keyframes viz-c-chain {
  0%, 18%  { opacity: 0; transform: translate3d(0, -0.35rem, 0); }
  40%, 86% { opacity: 1; transform: translate3d(0, 0, 0); }
  100%     { opacity: 0; transform: translate3d(0, -0.35rem, 0); }
}

/* The veil. Same colour as the stage it sits on, scaled from its bottom
   edge, which uncovers the chain top down. */
.viz-unfold__veil {
  position: absolute;
  inset: 0;
  background: var(--surface);
  transform-origin: bottom;
  will-change: transform;
  animation: viz-c-veil var(--viz-unfold-dur) var(--ease-settle) infinite;
}

@keyframes viz-c-veil {
  0%, 14%  { transform: scaleY(1); }
  40%, 86% { transform: scaleY(0); }
  100%     { transform: scaleY(1); }
}

.viz-unfold__count {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  color: var(--viz-accent);
  white-space: nowrap;
}


/* =====================================================================
   21  WIPE COMPARE - split screen wipe
   Teaching: same estate, before correlation and after.

   Two layers in one box. The correlated layer is uncovered by a seam
   travelling right, held, then covered again by the same seam travelling
   back, so the loop is one gesture out and back rather than a cut.

   The seam bar is a transform. The reveal is a clip-path inset on the
   upper layer, the one place in this batch that is not transform or
   opacity, because a wipe cannot be a transform without counter-scaling
   live type. clip-path inset is paint only and triggers no layout.

   Beats: before only (0 - 0.6s), wipe right (0.6 - 1.9s), hold on the
   correlated view (1.9 - 3.1s), wipe back (3.1 - 4s).
   ===================================================================== */

.viz-wipe { --viz-wipe-dur: 3.8s; }

.viz-wipe__pane {
  position: relative;
  min-height: 9.5rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  overflow: hidden;
}

.viz-wipe__layer {
  position: absolute;
  inset: 0;
  display: grid;
  align-content: center;
  gap: 0.625rem;
  padding: var(--sp-4);
  min-width: 0;
}

.viz-wipe__layer--after {
  background: var(--surface);
  clip-path: inset(0 100% 0 0);
  will-change: clip-path;
  animation: viz-c-wipe var(--viz-wipe-dur) var(--ease-in-out) infinite;
}

@keyframes viz-c-wipe {
  0%, 15%  { clip-path: inset(0 100% 0 0); }
  48%, 78% { clip-path: inset(0 0 0 0); }
  100%     { clip-path: inset(0 100% 0 0); }
}

.viz-wipe__tag {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  font-size: var(--fs-micro);
  font-weight: 650;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--fg-3);
}

.viz-wipe__tag--on { color: var(--viz-accent); }

/* Before: separate alert pills that mean nothing apart. */
.viz-wipe__pills {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.viz-wipe__pills li {
  padding: 0.1rem 0.4rem;
  border-radius: var(--r-xs);
  border: 1px solid var(--border-strong);
  font-family: var(--font-mono);
  font-size: 0.625rem;
  color: var(--fg-3);
  white-space: nowrap;
  animation: viz-c-flicker 2.4s var(--ease-in-out) infinite;
  animation-delay: calc(var(--i, 0) * 0.08s);
}

@keyframes viz-c-flicker {
  0%, 100% { opacity: 0.45; }
  50%      { opacity: 1; }
}

/* After: the same estate, re-hung off the one device upstream of it.
   Deliberately NOT an incident card. viz-collapse on index owns that
   payoff frame and no second component is allowed to land on it. */
.viz-wipe__group {
  display: grid;
  gap: 0.4rem;
  padding: 0.5rem 0.625rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--viz-accent);
  background: var(--surface);
  min-width: 0;
}

.viz-wipe__root {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  font-weight: 650;
  color: var(--viz-accent);
}

.viz-wipe__root .ico { color: var(--viz-accent); }

.viz-wipe__root i {
  font-family: var(--font-sans, inherit);
  font-size: var(--fs-micro);
  font-style: normal;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--fg-3);
}

/* Tied pills are settled, so they do not flicker. Their stillness against
   the flickering left-hand pills is the comparison. */
.viz-wipe__pills--tied li {
  animation: none;
  opacity: 1;
  border-color: var(--viz-line);
  border-left: 2px solid var(--viz-accent);
  color: var(--fg-2);
}

/* The seam spans the pane, so its translateX percentages are read
   against the pane rather than against a two pixel bar. */
.viz-wipe__seam {
  position: absolute;
  inset: 0;
  pointer-events: none;
  will-change: transform;
  animation: viz-c-seam var(--viz-wipe-dur) var(--ease-in-out) infinite;
}

.viz-wipe__seam::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 2px;
  background: var(--viz-accent);
}

.viz-wipe__seam::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 1px;
  width: 0.55rem;
  height: 0.55rem;
  margin: -0.275rem 0 0 -0.275rem;
  border-radius: 50%;
  background: var(--viz-accent);
}

@keyframes viz-c-seam {
  0%, 15%  { opacity: 0.4; transform: translate3d(0, 0, 0); }
  48%, 78% { opacity: 1; transform: translate3d(calc(100% - 2px), 0, 0); }
  100%     { opacity: 0.4; transform: translate3d(0, 0, 0); }
}

@media (max-width: 640px) {
  .viz-wipe__pane { min-height: 11.5rem; }
}


/* =====================================================================
   22  HANDOFF - relay between holders
   Teaching: context survives the shift change.

   Two holders, one token. The holders swap state across the boundary,
   the token crosses once on a shallow arc, and its three detail lines
   never change opacity for the whole loop. That last part is the
   teaching: what changes is the holder, not the detail.

   Beats: night holds (0 - 1.5s), pass (1.5 - 2.5s), day holds and the
   detail is marked intact (2.5 - 3.8s), reset.
   ===================================================================== */

.viz-handoff {
  --viz-handoff-dur: 3.6s;
  --viz-token: 12rem;
  --viz-handoff-lane-h: 6.25rem;
}

.viz-handoff__stage { display: grid; gap: var(--sp-4); }

.viz-handoff__shifts {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sp-4);
}

/* Holder 0 is lit from the start, holder 1 from 1.8s, because its whole
   animation is offset by half the loop. */
.viz-handoff__holder {
  display: grid;
  justify-items: center;
  gap: 0.25rem;
  padding: 0.625rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  text-align: center;
  min-width: 0;
  animation: viz-c-shift var(--viz-handoff-dur) var(--ease-out) infinite;
  /* Negative half-loop offset, so both shifts are inside one 3.6s cycle. */
  animation-delay: calc(var(--s, 0) * -1.8s);
}

@keyframes viz-c-shift {
  0%        { opacity: 0.45; border-color: var(--viz-line); transform: translate3d(0, 0, 0); }
  8%, 45%   { opacity: 1; border-color: var(--viz-accent); transform: translate3d(0, -2px, 0); }
  55%, 100% { opacity: 0.45; border-color: var(--viz-line); transform: translate3d(0, 0, 0); }
}

.viz-handoff__holder .ico { color: var(--viz-accent); }

.viz-handoff__who { font-size: var(--fs-meta); font-weight: 620; color: var(--fg-2); }

.viz-handoff__when {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  color: var(--fg-3);
}

/* The lane the token crosses, with the shift boundary drawn on it. */
.viz-handoff__lane {
  position: relative;
  height: var(--viz-handoff-lane-h);
  border-radius: var(--r-sm);
  border: 1px dashed var(--viz-line);
  background: var(--surface-2);
  padding: 0.5rem;
  overflow: hidden;
}

.viz-handoff__lane::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 0.4rem;
  bottom: 0.4rem;
  width: 1px;
  background: var(--viz-accent);
  animation: viz-c-boundary var(--viz-handoff-dur) var(--ease-out) infinite;
}

@keyframes viz-c-boundary {
  0%, 36%   { opacity: 0.3; transform: scaleY(0.86); }
  50%       { opacity: 1; transform: scaleY(1); }
  64%, 100% { opacity: 0.3; transform: scaleY(0.86); }
}

.viz-handoff__run {
  position: relative;
  height: 100%;
  will-change: transform;
  animation: viz-c-pass var(--viz-handoff-dur) var(--ease-settle) infinite;
}

@keyframes viz-c-pass {
  0%, 38%   { transform: translate3d(0, 0, 0); }
  50%       { transform: translate3d(calc((100% - var(--viz-token)) / 2), -0.3rem, 0); }
  62%, 100% { transform: translate3d(calc(100% - var(--viz-token)), 0, 0); }
}

.viz-handoff__token {
  display: grid;
  gap: 0.15rem;
  width: var(--viz-token);
  padding: 0.4rem 0.5rem;
  border-radius: var(--r-xs);
  border: 1.5px solid var(--viz-accent);
  background: var(--surface);
  box-shadow: var(--shadow-1);
}

.viz-handoff__id {
  display: flex;
  align-items: center;
  gap: 0.3rem;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  color: var(--fg-2);
}

/* These lines hold one opacity for the entire loop. Do not animate them.
   Their constancy is the component. */
.viz-handoff__detail {
  display: grid;
  gap: 0.1rem;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 0.625rem;
  line-height: 1.4;
  color: var(--fg-3);
}

.viz-handoff__detail li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.viz-handoff__intact {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  margin: 0;
  font-size: var(--fs-micro);
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--viz-accent);
  animation: viz-c-intact var(--viz-handoff-dur) var(--ease-out) infinite;
}

@keyframes viz-c-intact {
  0%, 58%  { opacity: 0; transform: translate3d(-0.3rem, 0, 0); }
  70%, 94% { opacity: 1; transform: translate3d(0, 0, 0); }
  100%     { opacity: 0; transform: translate3d(-0.3rem, 0, 0); }
}

@media (max-width: 640px) {
  .viz-handoff { --viz-token: 9.5rem; --viz-handoff-lane-h: 6.5rem; }
}


/* =====================================================================
   23  GAUGE - arc fill and settle
   Teaching: coverage is estate-wide or it is not coverage.

   A half ring rotated about its own bottom centre inside a half height
   box that clips it. Rotating from -180deg to 0deg sweeps the arc in
   from the left. No stroke-dashoffset, no conic gradient, no registered
   custom property. Transform only.

   NO INVENTED NUMBER. The dial settles on a named state, never a figure.

   Beats: at rest (0 - 0.25s), sweep (0.25 - 1.7s), overshoot and settle
   (1.7 - 2.0s), hold on estate-wide (2.0 - 3.5s), reset.
   ===================================================================== */

.viz-gauge {
  --viz-gauge-dur: 3.8s;
  --viz-gauge-w: min(16rem, 62cqw);
  --viz-gauge-t: 0.7rem;
}

.viz-gauge__stage {
  container-type: inline-size;
  display: grid;
  justify-items: center;
  gap: var(--sp-3);
}

.viz-gauge__dial {
  position: relative;
  width: var(--viz-gauge-w);
  height: calc(var(--viz-gauge-w) / 2);
  overflow: hidden;
}

.viz-gauge__track,
.viz-gauge__fill {
  position: absolute;
  left: 0;
  top: 0;
  box-sizing: border-box;
  width: var(--viz-gauge-w);
  height: calc(var(--viz-gauge-w) / 2);
  border: var(--viz-gauge-t) solid var(--viz-line);
  border-bottom: 0;
  /* Percentages, not lengths. Length radii of w and w/2 overflow the box
     and the browser then rescales every radius by the same factor, which
     flattens the arc into an arch with straight sides. 50% / 100% lands
     exactly on the box and is left alone. */
  border-radius: 50% 50% 0 0 / 100% 100% 0 0;
}

.viz-gauge__fill {
  border-color: var(--viz-accent);
  border-bottom: 0;
  transform-origin: 50% 100%;
  transform: rotate(-180deg);
  will-change: transform;
  animation: viz-c-sweep var(--viz-gauge-dur) var(--ease-in-out) infinite;
}

@keyframes viz-c-sweep {
  0%, 6%   { transform: rotate(-180deg); }
  48%, 88% { transform: rotate(0deg); }
  100%     { transform: rotate(-180deg); }
}

/* The marker is an invisible arm with a knob on its end, pivoted at the
   centre of the dial. A needle lying flat along the base is ambiguous at
   both ends of the sweep. A knob riding the arc is not. */
.viz-gauge__needle {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 3px;
  height: calc(var(--viz-gauge-w) / 2 - var(--viz-gauge-t) / 2);
  margin-left: -1px;
  /* The needle had no paint at all, so it rendered as nothing. It is the one
     element that tells you where the reading sits, so it gets a solid colour,
     a rounded cap and a little more weight than the hairline it was. */
  background: var(--viz-accent, #2A69CE);
  border-radius: 2px;
  box-shadow: 0 0 6px -1px rgba(42,105,206,.55);
  transform-origin: 50% 100%;
  transform: rotate(-90deg);
  will-change: transform;
  animation: viz-c-needle var(--viz-gauge-dur) var(--ease-in-out) infinite;
}

.viz-gauge__needle::after {
  content: "";
  position: absolute;
  top: 0;
  left: 50%;
  width: 0.8rem;
  height: 0.8rem;
  margin: -0.4rem 0 0 -0.4rem;
  border-radius: 50%;
  border: 2px solid var(--viz-accent);
  background: var(--surface);
}

/* Three degrees of overshoot, then settle. Enough to read as arrival. */
@keyframes viz-c-needle {
  0%, 6%   { transform: rotate(-90deg); }
  44%      { transform: rotate(93deg); }
  52%, 88% { transform: rotate(90deg); }
  100%     { transform: rotate(-90deg); }
}

.viz-gauge__hub {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 0.6rem;
  height: 0.6rem;
  margin: 0 0 0 -0.3rem;
  border-radius: 50%;
  background: var(--viz-accent);
}

.viz-gauge__scale {
  display: flex;
  justify-content: space-between;
  gap: 0.75rem;
  width: min(100%, calc(var(--viz-gauge-w) + 5rem));
  margin: 0.25rem 0 0;
  padding: 0;
  list-style: none;
  font-size: var(--fs-micro);
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--fg-3);
}

.viz-gauge__scale li { opacity: 0.55; }

.viz-gauge__scale li:last-child {
  color: var(--viz-accent);
  animation: viz-c-reach var(--viz-gauge-dur) var(--ease-out) infinite;
}

@keyframes viz-c-reach {
  0%, 44%  { opacity: 0.4; }
  56%, 88% { opacity: 1; }
  100%     { opacity: 0.4; }
}

.viz-gauge__state {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  padding: 0.25rem 0.65rem;
  border-radius: var(--r-full);
  border: 1px solid var(--viz-accent);
  background: var(--accent-soft);
  font-size: var(--fs-meta);
  font-weight: 620;
  color: var(--fg-2);
  animation: viz-c-state var(--viz-gauge-dur) var(--ease-settle) infinite;
}

@keyframes viz-c-state {
  0%, 46%  { opacity: 0; transform: translate3d(0, 0.3rem, 0); }
  58%, 88% { opacity: 1; transform: translate3d(0, 0, 0); }
  100%     { opacity: 0; transform: translate3d(0, 0.3rem, 0); }
}


/* =====================================================================
   24  SHARED HEARTBEAT
   Rule six of the house: no component rests for longer than 1.5s. Every
   block above holds for at most a second, and each also carries one of
   these on its own 2.2s clock, so any two frames sampled 1.5s apart
   differ somewhere.
   ===================================================================== */

.viz-c-live {
  display: inline-block;
  flex: 0 0 auto;
  width: 0.4rem;
  height: 0.4rem;
  border-radius: 50%;
  background: var(--warn);
  animation: viz-c-beat 2.2s var(--ease-in-out) infinite;
}

.viz-c-live--accent { background: var(--viz-accent); }

@keyframes viz-c-beat {
  0%, 100% { opacity: 1; transform: scale(1); }
  50%      { opacity: 0.25; transform: scale(0.7); }
}


/* =====================================================================
   25  REDUCED MOTION END STATES
   Killing an animation leaves the 0% frame, which for most of these is
   the reset and reads as broken. Each one is resolved here instead to
   the frame that carries the meaning.
   ===================================================================== */

@media (prefers-reduced-motion: reduce) {

  .viz-zoom *, .viz-zoom *::before, .viz-zoom *::after,
  .viz-reorder *, .viz-reorder *::before, .viz-reorder *::after,
  .viz-heat *, .viz-heat *::before, .viz-heat *::after,
  .viz-unfold *, .viz-unfold *::before, .viz-unfold *::after,
  .viz-wipe *, .viz-wipe *::before, .viz-wipe *::after,
  .viz-handoff *, .viz-handoff *::before, .viz-handoff *::after,
  .viz-gauge *, .viz-gauge *::before, .viz-gauge *::after,
  .viz-c-live {
    animation: none !important;
  }

  /* Zoom: the day narrowed to the window and the window expanded, both
     registers at once. That pairing is the comparison worth keeping. */
  .viz-zoom__ticks i { opacity: 0.28 !important; transform: none !important; }
  .viz-zoom__ticks i.is-hot { opacity: 0.95 !important; }
  .viz-zoom__window {
    opacity: 1 !important;
    transform: translateX(var(--viz-zoom-at)) scaleX(var(--viz-zoom-w)) !important;
  }
  .viz-zoom__grow { opacity: 1 !important; transform: scaleX(1) !important; }
  .viz-zoom__mark { opacity: 1 !important; transform: none !important; }

  /* Reorder: sorted, worst on top, header reading severity. */
  .viz-reorder__row { opacity: 1 !important; transform: none !important; }
  .viz-reorder__row--worst::before { opacity: 1 !important; }
  .viz-reorder__mode > span { opacity: 0 !important; }
  .viz-reorder__mode > span + span { opacity: 1 !important; transform: none !important; }

  /* Heat: the map at peak. The clustering is the whole picture. */
  .viz-heat__cell::after {
    opacity: calc(var(--h, 0) * 0.28) !important;
    transform: scale(1) !important;
  }
  .viz-heat__cell--core::before { opacity: 0.55 !important; transform: scale(1) !important; }

  /* Unfold: open, chain showing, veil retracted. */
  .viz-unfold__veil { transform: scaleY(0) !important; }
  .viz-unfold__body::before { transform: scaleY(1) !important; }
  .viz-unfold__chain li { opacity: 1 !important; transform: none !important; }
  .viz-unfold__chev { transform: rotate(90deg) !important; }

  /* Wipe: held at the halfway seam, so both sides are readable at once.
     It is the only frame in the loop where the comparison exists. */
  .viz-wipe__layer--after { clip-path: inset(0 50% 0 0) !important; }
  .viz-wipe__seam { opacity: 1 !important; transform: translate3d(50%, 0, 0) !important; }
  .viz-wipe__pills li { opacity: 0.85 !important; }

  /* Handoff: received, both holders drawn, detail intact and marked. */
  .viz-handoff__run { transform: translate3d(calc(100% - var(--viz-token)), 0, 0) !important; }
  .viz-handoff__holder { opacity: 1 !important; transform: none !important; }
  .viz-handoff__lane::before { opacity: 0.3 !important; transform: none !important; }
  .viz-handoff__intact { opacity: 1 !important; transform: none !important; }

  /* Gauge: settled at estate-wide. */
  .viz-gauge__fill { transform: rotate(0deg) !important; }
  .viz-gauge__needle { transform: rotate(90deg) !important; }
  .viz-gauge__scale li:last-child { opacity: 1 !important; }
  .viz-gauge__state { opacity: 1 !important; transform: none !important; }

  /* Heartbeats resolve lit. */
  .viz-c-live { opacity: 1 !important; transform: none !important; }
}
