/* =====================================================================
   PulsarOps AI - motion.css
   Icon scale + twelve teaching visualizations.

   Loads AFTER site.css. Reads the contextual tokens site.css sets on
   .section--dark / .section--light, so every component recolours itself
   when you move it between bands. Never hardcode a page colour here.

   Owned by the foundation agent. Page agents must NOT edit this file.
   Build only from _MOTION-LIBRARY.md.

   Contents
   00  Icon sizing scale and colour inheritance
   01  Visualization shell (.viz, .viz__stage, .viz__cap, gating, pause)
   02  AMBIENT FIELD          drift
   03  HEADLINE REVEAL        per character
   04  MARQUEE                continuous horizontal
   05  INGEST STREAMS         convergence
   06  CORRELATION GRAPH      snapping
   07  THREE-STAGE PIPELINE   sequential traversal
   08  NOISE COLLAPSE         collapse and merge
   09  MTTR RACE              comparative race
   10  ROOT CAUSE TOPOLOGY    propagation outward
   11  AI ASSISTANT           typed sequence
   12  PERIMETER              containment and circulation
   13  VENDOR LATTICE         sequenced grid pulse
   14  LOG WATERFALL          continuous scroll with a pick-out
   15  SPOTLIGHT PLATE        cursor tracked reveal
   16  Reduced motion end states

   TIMING RULE
   Every component that tells a story completes its full cycle in 4s or
   less. Three or four beats, about a second each, and a hold of under a
   second and a half at the end. The only slow loops left are the ambient
   field, the marquee, and the waterfall scroll, none of which has a
   beginning. The waterfall's pick-out beat runs on its own 3.2s clock.
   ===================================================================== */


/* =====================================================================
   00  ICON SIZING SCALE
   One element, one class, one size token. Icons inherit colour from the
   text around them, so a section context switch recolours them for free.
   ===================================================================== */

.ico {
  --ico-size: 1.25rem;
  width: var(--ico-size);
  height: var(--ico-size);
  flex: 0 0 auto;
  display: inline-block;
  vertical-align: -0.18em;
  color: inherit;
  fill: none;
  stroke: currentColor;
  overflow: visible;
}

.ico--xs { --ico-size: 0.875rem; }
.ico--sm { --ico-size: 1rem; }
.ico--md { --ico-size: 1.25rem; }
.ico--lg { --ico-size: 1.5rem; }
.ico--xl { --ico-size: 2rem; }
.ico--2xl { --ico-size: 2.75rem; }

/* Tint helpers. Nothing else in the sheet ever sets an icon colour. */
.ico--accent { color: var(--accent); }
.ico--mute { color: var(--fg-3); }

/* An icon sitting on its own tile. */
.ico-tile {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--accent);
  flex: 0 0 auto;
}

.ico-tile--lg { width: 3rem; height: 3rem; border-radius: var(--r-md); }


/* =====================================================================
   01  VISUALIZATION SHELL

   Every component is:
     <div class="viz viz-NAME" data-viz>
       <div class="viz__stage"> ...the picture... </div>
       <p class="viz__cap">what it shows</p>
     </div>

   data-viz          IntersectionObserver gate. Off screen means paused.
   data-viz-pause    also pause on :hover and :focus-within.
   ===================================================================== */

.viz {
  --viz-line: var(--border);
  --viz-accent: var(--accent);
  --viz-dim: var(--fg-3);
  --viz-pad: clamp(1rem, 0.65rem + 1.4vw, 1.625rem);
  --viz-r: var(--r-lg);
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  min-width: 0;
  /* The stage contains its inline size, so it has no intrinsic width to
     shrink-wrap to. Any left-aligned flex column it lands in (.split__copy,
     for one) would collapse it to nothing without these two lines. */
  width: 100%;
  align-self: stretch;
}

/* contain: inline-size is the important line. A stage holds nowrap
   monospace lines, and overflow:hidden clips them at paint time without
   changing what they contribute to intrinsic width. Containment cuts that
   contribution to zero, so a stage is always as wide as its column and
   never one character wider than the page. */
.viz__stage {
  position: relative;
  overflow: hidden;
  contain: inline-size;
  border-radius: var(--viz-r);
  border: 1px solid var(--viz-line);
  background: var(--surface);
  padding: var(--viz-pad);
  box-shadow: var(--shadow-1);
  min-width: 0;
}

.viz__cap {
  margin: 0;
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  font-size: var(--fs-meta);
  line-height: 1.5;
  color: var(--fg-3);
  letter-spacing: var(--tr-meta);
}

.viz__cap::before {
  content: "";
  flex: 0 0 auto;
  width: 0.375rem;
  height: 0.375rem;
  margin-top: 0.5em;
  border-radius: 50%;
  background: var(--viz-accent);
}

/* Grid and flex items default to min-width:auto, which is the min-content
   width of their deepest unbreakable line. One nowrap monospace log line
   inside a visualization will otherwise widen the whole page. Any container
   that can hold a .viz has to be allowed to be narrower than its content,
   because the stage clips at paint time and that is the intended reading. */
.split__copy,
.split__media,
.sec-head__aside,
[class*="viz"] > * { min-width: 0; }

/* Small label used inside stages. */
.viz__label {
  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);
}

/* ---- Gating -----------------------------------------------------
   Default is paused, and only under .has-js, so a script failure or a
   no-JS reader still gets every loop running rather than a dead page. */

.has-js [data-viz]:not(.is-live),
.has-js [data-viz]:not(.is-live) *,
.has-js [data-viz]:not(.is-live) *::before,
.has-js [data-viz]:not(.is-live) *::after {
}

/* =====================================================================
   02  AMBIENT FIELD - drift
   Teaches nothing, and is allowed not to. It is the only purely
   atmospheric layer besides the spotlight plate.
   ===================================================================== */

.viz-field {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
}

.viz-field__glow {
  position: absolute;
  inset: -25%;
  background:
    radial-gradient(36% 40% at 22% 26%, rgba(42, 105, 206, 0.40), transparent 68%),
    radial-gradient(30% 34% at 76% 16%, rgba(91, 155, 217, 0.22), transparent 70%),
    radial-gradient(44% 38% at 56% 98%, rgba(12, 62, 138, 0.46), transparent 72%);
  will-change: transform;
  animation: viz-drift var(--viz-field-dur, 30s) ease-in-out infinite;
}

.section--light .viz-field__glow {
  background:
    radial-gradient(36% 40% at 22% 26%, rgba(42, 105, 206, 0.14), transparent 68%),
    radial-gradient(30% 34% at 76% 16%, rgba(91, 155, 217, 0.12), transparent 70%),
    radial-gradient(44% 38% at 56% 98%, rgba(12, 62, 138, 0.10), transparent 72%);
}

@keyframes viz-drift {
  0%, 100% { transform: translate3d(-2%, 1.6%, 0) scale(1.04); }
  50%      { transform: translate3d(2.6%, -2%, 0) scale(1.11); }
}

/* Put .has-field on the section so its content sits above the glow. */
.has-field { position: relative; }
.has-field > .wrap { position: relative; z-index: 1; }


/* =====================================================================
   03  HEADLINE REVEAL - per character
   JS wraps characters and writes --i. Transitions, not keyframes, so a
   reveal that lands mid-scroll is interruptible.
   ===================================================================== */

.viz-split__w { display: inline-block; white-space: nowrap; }

.viz-split__c {
  display: inline-block;
  opacity: 0;
  transform: translateY(0.5em);
  will-change: transform, opacity;
  transition:
    opacity 0.46s var(--ease-out) calc(var(--i, 0) * var(--viz-split-step, 16ms)),
    transform 0.68s var(--ease-settle) calc(var(--i, 0) * var(--viz-split-step, 16ms));
}

.viz-split.is-in .viz-split__c { opacity: 1; transform: none; }


/* =====================================================================
   04  MARQUEE - continuous horizontal
   JS clones the track content once. Items carry their own trailing pad
   instead of a flex gap, so translate(-50%) lands on an exact seam.
   ===================================================================== */

.viz-marquee {
  overflow: hidden;
  /* Full bleed. This rail is claiming reach, and a claim about reach that
     stops at the text column argues against itself. The negative margin
     trick is safe here because body already carries overflow-x: hidden,
     so 100vw cannot open a horizontal scrollbar. */
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
}

.viz-marquee__track {
  display: flex;
  width: max-content;
  will-change: transform;
  /* 72s over a full screen width crawled. 26s reads as movement. */
  animation: viz-marquee var(--viz-mq-dur, 26s) linear infinite;
}

/* The gap is what makes one set wider than the rail. Six items at 3rem
   left a set narrower than the wrap, and the track walked off screen. */
.viz-marquee__item {
  flex: 0 0 auto;
  padding-inline-end: var(--viz-mq-gap, 6rem);
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-size: var(--fs-sm);
  color: var(--fg-2);
  white-space: nowrap;
}

@keyframes viz-marquee {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}

/* =====================================================================
   05  INGEST STREAMS - convergence
   Teaching: everything on the network already emits signal, and it
   arrives in different shapes from different vendors.
   ===================================================================== */

.viz-ingest {
  --viz-ingest-dur: 3.5s;
  --viz-pkt: 1.125rem;
  --viz-lane-gap: 0.5rem;
  --viz-tag-w: 10.5rem;
}

.viz-ingest__stage {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--sp-5);
}

.viz-ingest__lanes { display: grid; gap: var(--viz-lane-gap); min-width: 0; }

.viz-ingest__lane {
  display: grid;
  grid-template-columns: var(--viz-tag-w) minmax(0, 1fr);
  align-items: center;
  gap: 0.75rem;
  min-width: 0;
}

/* nowrap keeps each label on one line. overflow:hidden is the guard that
   stops a long label bleeding across the packet track if the column ever
   runs short of the text it holds. */
.viz-ingest__tag {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
  overflow: hidden;
  font-size: var(--fs-meta);
  color: var(--fg-2);
  white-space: nowrap;
}

.viz-ingest__tag .ico { color: var(--viz-accent); }

.viz-ingest__track { position: relative; height: 1.5rem; min-width: 0; }

.viz-ingest__track::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 1px;
  background: var(--viz-line);
}

.viz-ingest__run {
  position: absolute;
  inset: 0;
  will-change: transform;
  animation: viz-run var(--viz-ingest-dur) cubic-bezier(0.45, 0, 0.35, 1) infinite;
  /* Five lanes at 0.12s apart is 0.48s of stagger, so lane 4 still lands
     inside the 3.6s cycle. The old offset was a full second per lane. */
  animation-delay: calc(var(--i, 0) * 0.12s);
}

.viz-ingest__pkt {
  position: absolute;
  left: 0;
  top: 50%;
  width: var(--viz-pkt);
  height: var(--viz-pkt);
  margin-top: calc(var(--viz-pkt) / -2);
  border: 1.5px solid var(--viz-accent);
  background: var(--surface);
  animation: viz-pkt var(--viz-ingest-dur) linear infinite;
  animation-delay: inherit;
}

/* Different vendors, different shapes. This is the point of the lane. */
.viz-ingest__pkt--sq { border-radius: 2px; }
.viz-ingest__pkt--ci { border-radius: 50%; }
.viz-ingest__pkt--di { border-radius: 2px; transform: rotate(45deg) scale(0.86); }
.viz-ingest__pkt--pi { border-radius: 999px; width: calc(var(--viz-pkt) * 1.7); }
.viz-ingest__pkt--sk { border-radius: 2px; transform: skewX(-16deg); }

/* Three beats: emit, cross, land. There is no readable dwell at the far
   end, because the packet has already faded by the time it parks. */
@keyframes viz-run {
  0%, 6%    { transform: translate3d(0, 0, 0); }
  72%, 100% { transform: translate3d(calc(100% - var(--viz-pkt) * 1.7), 0, 0); }
}

@keyframes viz-pkt {
  0%, 4%    { opacity: 0; }
  14%, 64%  { opacity: 1; }
  74%, 100% { opacity: 0; }
}

.viz-ingest__sink {
  display: grid;
  justify-items: center;
  gap: 0.5rem;
  padding-left: var(--sp-3);
  border-left: 1px solid var(--viz-line);
}

.viz-ingest__well {
  position: relative;
  display: grid;
  place-items: center;
  width: 3.25rem;
  height: 3.25rem;
  border-radius: var(--r-md);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  color: var(--viz-accent);
}

.viz-ingest__well::after {
  content: "";
  position: absolute;
  inset: -0.4rem;
  border-radius: var(--r-lg);
  border: 1px solid var(--viz-accent);
  opacity: 0.3;
  animation: viz-sink var(--viz-ingest-dur) ease-out infinite;
}

@keyframes viz-sink {
  0%, 62%   { transform: scale(1); opacity: 0.22; }
  78%       { transform: scale(1.14); opacity: 0.85; }
  94%, 100% { transform: scale(1); opacity: 0.22; }
}

.viz-ingest__sink b {
  font-size: var(--fs-micro);
  font-weight: 650;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--fg-3);
}

@media (max-width: 640px) {
  /* 9.75rem is the measured width of the longest label, Configs and
     inventory, at --fs-meta with its icon. Anything narrower and the
     packet track runs underneath the text. */
  .viz-ingest { --viz-tag-w: 9.75rem; --viz-lane-gap: 0.55rem; }
  .viz-ingest__lane { gap: 0.5rem; }
  .viz-ingest__stage { grid-template-columns: minmax(0, 1fr); }
  .viz-ingest__sink {
    border-left: 0;
    border-top: 1px solid var(--viz-line);
    padding-left: 0;
    padding-top: var(--sp-4);
    grid-auto-flow: column;
    /* Every packet lands at the RIGHT end of its lane. Centred, the collector
       asked the eye to travel left while the motion travelled right, so the
       five signals appeared to arrive nowhere. It belongs under the landing
       point. */
    justify-content: end;
    align-items: center;
  }
}


/* =====================================================================
   06  CORRELATION GRAPH - snapping
   Teaching: events that look unrelated in separate tools are related,
   and the relationship is what matters.

   Offsets are container query units, so the scatter distance tracks the
   stage width and the loop survives a resize.
   ===================================================================== */

.viz-correlate { --viz-corr-dur: 3.6s; }

.viz-correlate__stage { padding: 0; }

.viz-correlate__field {
  container-type: inline-size;
  position: relative;
  aspect-ratio: 12 / 5;
  min-height: 13rem;
  max-height: 22rem;
}

.viz-correlate__links {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.viz-correlate__link {
  stroke: var(--viz-accent);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  opacity: 0;
  animation: viz-link var(--viz-corr-dur) ease-in-out infinite;
  /* Eight links at 55ms is 0.385s of stagger. The last one is lit well
     before the cluster breaks apart again at 82%. */
  animation-delay: calc(var(--i, 0) * 50ms);
}

@keyframes viz-link {
  0%, 36%   { opacity: 0; }
  50%, 78%  { opacity: 0.55; }
  90%, 100% { opacity: 0; }
}

.viz-correlate__dot {
  position: absolute;
  left: var(--cx);
  top: var(--cy);
  display: grid;
  place-items: center;
  width: 1.875rem;
  height: 1.875rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--border-strong);
  background: var(--surface-2);
  color: var(--fg-3);
  will-change: transform;
  animation: viz-snap var(--viz-corr-dur) cubic-bezier(0.5, 0, 0.2, 1) infinite;
}

/* One dot in the cluster is the fault. It stays lit through the hold. */
.viz-correlate__dot--seed {
  width: 2.25rem;
  height: 2.25rem;
  border-width: 1.5px;
  border-color: var(--viz-accent);
  background: var(--accent-soft);
  color: var(--viz-accent);
  /* Breathes on its own clock, so the clustered hold is never a still frame. */
  animation-name: viz-snap, viz-seed;
  animation-duration: var(--viz-corr-dur), 1.7s;
  animation-timing-function: cubic-bezier(0.5, 0, 0.2, 1), ease-in-out;
  animation-iteration-count: infinite, infinite;
}

@keyframes viz-seed {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.4; }
}

/* Three beats: scattered, snapped, released. */
@keyframes viz-snap {
  0%, 10%   { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))); }
  40%, 82%  { transform: translate(-50%, -50%); }
  100%      { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))); }
}

.viz-correlate__ring {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 34cqw;
  height: 34cqw;
  margin: -17cqw 0 0 -17cqw;
  border-radius: 50%;
  border: 1px dashed var(--viz-accent);
  opacity: 0;
  animation: viz-hold var(--viz-corr-dur) ease-in-out infinite;
}

@keyframes viz-hold {
  0%, 40%   { opacity: 0; transform: scale(0.9); }
  52%, 80%  { opacity: 0.4; transform: scale(1); }
  90%, 100% { opacity: 0; transform: scale(0.9); }
}

.viz-correlate__note {
  position: absolute;
  left: 50%;
  bottom: 0.75rem;
  transform: translateX(-50%);
  font-size: var(--fs-micro);
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--viz-accent);
  opacity: 0;
  animation: viz-note var(--viz-corr-dur) ease-in-out infinite;
  white-space: nowrap;
}

@keyframes viz-note {
  0%, 46%   { opacity: 0; }
  56%, 80%  { opacity: 1; }
  90%, 100% { opacity: 0; }
}


/* =====================================================================
   07  THREE-STAGE PIPELINE - sequential traversal
   Teaching: there are three distinct stages, and evidence is carried
   through all of them. Stage names are the platform's own.
   ===================================================================== */

.viz-pipeline {
  --viz-pipe-dur: 3.6s;
  --viz-pay: 8.5rem;
}

.viz-pipeline__stages {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--sp-3);
  margin: 0;
  padding: 0;
  list-style: none;
}

.viz-pipeline__stage {
  position: relative;
  border-radius: var(--r-md);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  padding: var(--sp-4);
  min-width: 0;
}

.viz-pipeline__stage::before {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  border: 1.5px solid var(--viz-accent);
  opacity: 0;
  pointer-events: none;
  animation: viz-lit var(--viz-pipe-dur) ease-in-out infinite;
  /* Negative phase offset: every stage is already inside the loop at t=0,
     so the whole three-beat cycle completes in one --viz-pipe-dur. */
  animation-delay: calc(var(--i, 0) * var(--viz-pipe-dur) / 3 - var(--viz-pipe-dur));
}

/* Three beats, one per stage, a third of the loop each. The delay is
   still --viz-pipe-dur / 3, so the hand-off lands on the seam. */
@keyframes viz-lit {
  0%, 2%    { opacity: 0; }
  8%, 30%   { opacity: 1; }
  36%, 100% { opacity: 0; }
}

.viz-pipeline__num {
  font-family: var(--font-mono);
  font-size: var(--fs-micro);
  color: var(--viz-accent);
  letter-spacing: 0.1em;
}

.viz-pipeline__name {
  display: block;
  margin-top: 0.25rem;
  font-size: var(--fs-sm);
  font-weight: 640;
  color: var(--fg);
  letter-spacing: var(--tr-5);
}

.viz-pipeline__sub {
  display: block;
  margin-top: 0.125rem;
  font-size: var(--fs-meta);
  color: var(--fg-3);
}

.viz-pipeline__rail {
  position: relative;
  height: 2.25rem;
  margin-top: var(--sp-4);
}

.viz-pipeline__rail::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 1px;
  background: var(--viz-line);
}

.viz-pipeline__run {
  position: absolute;
  inset: 0;
  will-change: transform;
  animation: viz-carry var(--viz-pipe-dur) cubic-bezier(0.6, 0, 0.2, 1) infinite;
}

.viz-pipeline__payload {
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: var(--viz-pay);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.375rem;
  padding: 0.3rem 0.5rem;
  border-radius: var(--r-full);
  border: 1px solid var(--viz-accent);
  background: var(--surface);
  color: var(--viz-accent);
  font-size: var(--fs-micro);
  font-weight: 650;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  white-space: nowrap;
  /* Breathes on its own clock, so a parked payload is never a still frame. */
  animation: viz-breathe 1.9s ease-in-out infinite;
}

@keyframes viz-breathe {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.62; }
}

@keyframes viz-carry {
  0%        { transform: translate3d(calc(16.667% - var(--viz-pay) / 2), 0, 0); opacity: 0; }
  5%, 28%   { transform: translate3d(calc(16.667% - var(--viz-pay) / 2), 0, 0); opacity: 1; }
  33%, 61%  { transform: translate3d(calc(50% - var(--viz-pay) / 2), 0, 0); opacity: 1; }
  66%, 93%  { transform: translate3d(calc(83.333% - var(--viz-pay) / 2), 0, 0); opacity: 1; }
  99%, 100% { transform: translate3d(calc(83.333% - var(--viz-pay) / 2), 0, 0); opacity: 0; }
}

@media (max-width: 720px) {
  .viz-pipeline { --viz-pay: 7rem; }
  .viz-pipeline__stages { grid-template-columns: minmax(0, 1fr); }
  .viz-pipeline__rail { display: none; }
}


/* =====================================================================
   08  NOISE COLLAPSE - collapse and merge
   Teaching: one fault produces dozens of alerts, and they are all the
   same incident. The clearest single picture of the platform.
   ===================================================================== */

.viz-collapse {
  --viz-collapse-dur: 3.5s;
  --viz-row-pitch: 2rem;
  --viz-row-mid: 4;
  /* Nine rows at 0.06s is 0.48s of stagger. Row 8 has finished converging
     at 0.48s plus 52% of 4s, which is inside the cycle with room left. */
  --viz-collapse-step: 0.06s;
}

.viz-collapse__stage {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) auto minmax(0, 1fr);
  align-items: center;
  gap: var(--sp-4);
}

.viz-collapse__deck { min-width: 0; }

.viz-collapse__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);
}

.viz-collapse__rows {
  display: grid;
  grid-auto-rows: var(--viz-row-pitch);
  margin: 0;
  padding: 0;
  list-style: none;
}

.viz-collapse__row {
  display: grid;
  grid-template-columns: auto minmax(0, 7.5rem) minmax(0, 1fr) auto;
  align-items: center;
  gap: 0.5rem;
  font-size: var(--fs-meta);
  color: var(--fg-3);
  min-width: 0;
  will-change: transform, opacity;
  animation: viz-row var(--viz-collapse-dur) cubic-bezier(0.5, 0, 0.2, 1) infinite;
  animation-delay: calc(var(--i, 0) * var(--viz-collapse-step));
}

.viz-collapse__row .ico { color: var(--warn); }
.viz-collapse__row b { color: var(--fg-2); font-weight: 600; font-family: var(--font-mono); font-size: 0.75rem; }
.viz-collapse__row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.viz-collapse__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);
}

/* Three beats: the list arrives, it converges on the middle row, it is
   gone. The reset back to the top happens at zero opacity, so the jump
   from 100% to 0% is never seen. */
@keyframes viz-row {
  0%       { opacity: 0; transform: translate3d(0, -0.5rem, 0) scale(0.98); }
  7%, 34%  { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
  52%, 100% {
    opacity: 0;
    transform:
      translate3d(20%, calc((var(--viz-row-mid) - var(--i, 0)) * var(--viz-row-pitch)), 0)
      scale(0.86);
  }
}

.viz-collapse__arrow {
  display: grid;
  place-items: center;
  color: var(--viz-accent);
  animation: viz-merge-arrow var(--viz-collapse-dur) ease-in-out infinite;
}

@keyframes viz-merge-arrow {
  0%, 34%   { opacity: 0.2; transform: translate3d(-0.4rem, 0, 0); }
  50%, 74%  { opacity: 1; transform: translate3d(0.3rem, 0, 0); }
  92%, 100% { opacity: 0.2; transform: translate3d(-0.4rem, 0, 0); }
}

/* Alerts keep arriving whether or not the merge is mid cycle. This dot is
   the heartbeat of the component, and it is why no sampled frame repeats
   the one before it. */
.viz-collapse__live {
  width: 0.4rem;
  height: 0.4rem;
  border-radius: 50%;
  background: var(--warn);
  animation: viz-heartbeat 1.5s ease-in-out infinite;
}

@keyframes viz-heartbeat {
  0%, 100% { opacity: 1; transform: scale(1); }
  50%      { opacity: 0.25; transform: scale(0.72); }
}

.viz-collapse__card {
  border-radius: var(--r-md);
  border: 1.5px solid var(--viz-accent);
  background: var(--surface-2);
  padding: var(--sp-4);
  min-width: 0;
  will-change: transform, opacity;
  animation: viz-card var(--viz-collapse-dur) cubic-bezier(0.16, 1, 0.3, 1) infinite;
}

/* The card owns the back third of the loop, 1.36s of readable hold. It
   is the payoff of the sentence, so it gets the dwell the rows gave up. */
@keyframes viz-card {
  0%, 48%   { opacity: 0; transform: scale(0.94) translate3d(-0.75rem, 0, 0); }
  62%, 96%  { opacity: 1; transform: scale(1) translate3d(0, 0, 0); }
  100%      { opacity: 0; transform: scale(0.97) translate3d(0, 0, 0); }
}

.viz-collapse__title {
  margin: 0.5rem 0 0;
  font-size: var(--fs-3);
  font-weight: 660;
  letter-spacing: var(--tr-3);
  color: var(--fg);
  line-height: 1.1;
}

.viz-collapse__meta { margin: 0.25rem 0 0; font-size: var(--fs-meta); color: var(--fg-3); }

.viz-collapse__chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.375rem;
  margin: 0.75rem 0 0;
  padding: 0;
  list-style: none;
}

.viz-collapse__chips li {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  padding: 0.15rem 0.45rem;
  border-radius: var(--r-xs);
  border: 1px solid var(--border);
  background: var(--surface);
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  color: var(--fg-2);
}

@media (max-width: 860px) {
  .viz-collapse__stage { grid-template-columns: minmax(0, 1fr); }
  /* The arrow has to point at the incident card, which is BELOW the alert
     list once this stacks. A static `transform: rotate(90deg)` here did
     nothing: the element runs viz-merge-arrow, and a running animation
     owns the transform property outright, so the rotation was discarded
     every frame. The direction has to live in the keyframes instead. */
  .viz-collapse__arrow { animation-name: viz-merge-arrow-down; }
  .viz-collapse__row { grid-template-columns: auto minmax(0, 6.5rem) minmax(0, 1fr) auto; }
  /* The rows converge on the incident, so they have to travel toward where
     the incident actually is. Stacked, that is straight down, not the 20%
     rightward drift the two column layout wants. That sideways travel was
     also what pushed the severity chips past the card's right edge. */
  .viz-collapse__row { animation-name: viz-row-down; }
  /* Nine rows at 2rem reserve 18rem that reads as an empty plate for the
     back half of the loop. Tighter pitch, less void. */
  .viz-collapse { --viz-row-pitch: 1.6rem; }

  /* Stacked, the card sits below the alert list and its slot is reserved
     for the whole loop. At opacity 0 that slot reads as an empty plate for
     the front half of the cycle. Resting it at a low opacity turns the same
     reserved height into a waiting incident slot that then fills. */
  .viz-collapse__card { animation-name: viz-card-stacked; }
}

/* rotate() first, so the translate that follows runs along the rotated axis
   and the arrow drifts DOWN toward the card rather than sideways past it. */
/* The list holds far longer than it used to. Nine rows reserve their height
   for the whole loop, so every percent the rows spend faded is a percent the
   stacked layout shows an empty plate. Holding to 46% and converging by 64%
   cuts that blank window from 48% of the cycle to 26%. */
@keyframes viz-row-down {
  0%       { opacity: 0; transform: translate3d(0, -0.5rem, 0) scale(0.98); }
  6%, 46%  { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
  64%, 90% {
    opacity: 0;
    transform:
      translate3d(0, calc((var(--viz-row-mid) - var(--i, 0)) * var(--viz-row-pitch) + 0.6rem), 0)
      scale(0.86);
  }
  100%     { opacity: 0; transform: translate3d(0, -0.5rem, 0) scale(0.98); }
}

@keyframes viz-merge-arrow-down {
  0%, 46%   { opacity: 0.2; transform: rotate(90deg) translate3d(-0.4rem, 0, 0); }
  60%, 82%  { opacity: 1;   transform: rotate(90deg) translate3d(0.3rem, 0, 0); }
  94%, 100% { opacity: 0.2; transform: rotate(90deg) translate3d(-0.4rem, 0, 0); }
}

@keyframes viz-card-stacked {
  0%, 46%   { opacity: 0.18; transform: scale(0.99) translate3d(0, 0, 0); }
  62%, 96%  { opacity: 1; transform: scale(1) translate3d(0, 0, 0); }
  100%      { opacity: 0.18; transform: scale(0.99) translate3d(0, 0, 0); }
}


/* =====================================================================
   09  MTTR RACE - comparative race
   Teaching: the saving is hours of triage, not minutes.

   Both fills grow at the SAME rate. Slow reaches scaleX(1) at 62% of the
   loop, fast reaches scaleX(0.125) at 7.75%. Identical slope, one eighth
   the distance, so the difference you read is time, not speed.
   ===================================================================== */

.viz-race { --viz-race-dur: 3.8s; }

.viz-race__rows { display: grid; gap: var(--sp-4); }

.viz-race__row { display: grid; gap: 0.5rem; min-width: 0; }

.viz-race__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  font-size: var(--fs-sm);
}

.viz-race__who { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--fg-2); }
.viz-race__who .ico { color: var(--fg-3); }
.viz-race__row--fast .viz-race__who { color: var(--fg); font-weight: 620; }
.viz-race__row--fast .viz-race__who .ico { color: var(--viz-accent); }

.viz-race__time {
  font-family: var(--font-mono);
  font-size: var(--fs-meta);
  color: var(--fg-3);
  font-variant-numeric: tabular-nums;
}

.viz-race__track {
  position: relative;
  height: 0.75rem;
  border-radius: var(--r-full);
  background: var(--surface-2);
  border: 1px solid var(--viz-line);
  overflow: hidden;
}

.viz-race__fill {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  transform-origin: left center;
  will-change: transform;
  background: var(--fg-3);
  animation: viz-race-slow var(--viz-race-dur) linear infinite;
}

.viz-race__row--fast .viz-race__fill {
  background: var(--viz-accent);
  animation-name: viz-race-fast;
}

@keyframes viz-race-slow {
  0%        { transform: scaleX(0); }
  62%, 88%  { transform: scaleX(1); }
  100%      { transform: scaleX(0); }
}

@keyframes viz-race-fast {
  0%          { transform: scaleX(0); }
  7.75%, 88%  { transform: scaleX(0.125); }
  100%        { transform: scaleX(0); }
}

/* Hour marks, so "hours" is legible without a sentence saying so. */
.viz-race__ticks {
  display: grid;
  grid-template-columns: repeat(8, minmax(0, 1fr));
  margin-top: 0.375rem;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  color: var(--fg-3);
  opacity: 0.7;
}

/* The label belongs at the RIGHT of its cell: the mark after the first
   eighth of the track is the 1 hour mark, not the zero. */
.viz-race__ticks span {
  border-right: 1px solid var(--viz-line);
  padding-right: 0.25rem;
  text-align: right;
}

.viz-race__flag {
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.1rem 0.4rem;
  border-radius: var(--r-full);
  border: 1px solid var(--viz-accent);
  background: var(--surface);
  color: var(--viz-accent);
  font-size: 0.625rem;
  font-weight: 650;
  white-space: nowrap;
  opacity: 0;
  animation: viz-flag var(--viz-race-dur) ease-out infinite;
}

.viz-race__flagline { position: relative; height: 1.375rem; }

/* The 8 hour marker sits on the right edge, so it is pulled back by its
   own width rather than by a transform the keyframe would overwrite. */
.viz-race__flag--end { margin-left: -2.9rem; }

/* Each marker lands the instant ITS bar finishes. That is the whole
   comparison: one row is closed inside the first hour, the other is still
   filling for another seven. */
@keyframes viz-flag {
  0%, 62%   { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
  69%, 88%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  97%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
}

@keyframes viz-flag-fast {
  0%, 7.75% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
  15%, 88%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  97%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
}

.viz-race__row--fast .viz-race__flag { animation-name: viz-flag-fast; }

.viz-race__gain {
  margin: var(--sp-4) 0 0;
  padding-top: var(--sp-4);
  border-top: 1px solid var(--viz-line);
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: var(--fs-sm);
  color: var(--fg-2);
}

.viz-race__gain b { color: var(--fg); font-weight: 660; }


/* =====================================================================
   10  ROOT CAUSE TOPOLOGY - propagation outward
   Teaching: the device that alerts loudest is usually not the device
   that broke.
   ===================================================================== */

.viz-rootcause { --viz-rc-dur: 3.5s; }

.viz-rootcause__tiers { display: grid; gap: var(--sp-5); }

.viz-rootcause__tier {
  display: grid;
  grid-auto-flow: column;
  gap: clamp(0.75rem, 3vw, 2.5rem);
  width: max-content;
  max-width: 100%;
  margin-inline: auto;
}

.viz-rootcause__dev {
  position: relative;
  display: grid;
  justify-items: center;
  gap: 0.3rem;
  width: 4.25rem;
  text-align: center;
}

.viz-rootcause__box {
  position: relative;
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.25rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  color: var(--fg-3);
}

.viz-rootcause__dev b {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  font-weight: 500;
  color: var(--fg-3);
  letter-spacing: 0;
}

/* The alert ring. Later tiers ring harder, which is the whole lesson. */
.viz-rootcause__ring {
  position: absolute;
  inset: -0.3rem;
  border-radius: var(--r-md);
  border: 1.5px solid var(--warn);
  opacity: 0;
  animation: viz-fault var(--viz-rc-dur) ease-out infinite;
  /* Three tiers at 0.22s. The bottom tier is ringing by 0.66s, which
     leaves the whole back half of the loop for the verdict. */
  animation-delay: calc(var(--t, 0) * 0.22s);
}

/* A ring that throbs twice rather than holds. An alarm that keeps going
   off is truer to a NOC, and it means no two sampled frames match. */
@keyframes viz-fault {
  0%, 6%    { opacity: 0; transform: scale(0.86); }
  16%       { opacity: 0.95; transform: scale(1); }
  38%       { opacity: 0.34; transform: scale(1.04); }
  54%       { opacity: 0.9; transform: scale(1); }
  80%, 100% { opacity: 0; transform: scale(1.06); }
}

.viz-rootcause__dev--leaf .viz-rootcause__ring { border-width: 2px; }

/* The upstream node is marked only after the noise has peaked. */
.viz-rootcause__blame {
  position: absolute;
  inset: -0.45rem;
  border-radius: var(--r-md);
  border: 2px solid var(--viz-accent);
  opacity: 0;
  animation: viz-blame var(--viz-rc-dur) cubic-bezier(0.16, 1, 0.3, 1) infinite;
}

.viz-rootcause__tagline {
  position: absolute;
  top: -1.55rem;
  left: 50%;
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.1rem 0.4rem;
  border-radius: var(--r-full);
  background: var(--viz-accent);
  color: var(--ink-900);
  font-size: 0.625rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  white-space: nowrap;
  opacity: 0;
  animation: viz-blame var(--viz-rc-dur) cubic-bezier(0.16, 1, 0.3, 1) infinite;
}

/* Beat three: the noise has peaked, and the node that actually broke is
   named. 66% to 96% of a 4s loop is 1.2s of readable verdict. */
@keyframes viz-blame {
  0%, 56%   { opacity: 0; transform: translate(-50%, 0.35rem) scale(0.94); }
  66%, 96%  { opacity: 1; transform: translate(-50%, 0) scale(1); }
  100%      { opacity: 0; transform: translate(-50%, 0) scale(0.98); }
}

/* The ring is not x-centered, so it needs its own copy without the -50%. */
.viz-rootcause__blame {
  animation-name: viz-blame-ring;
}

@keyframes viz-blame-ring {
  0%, 56%   { opacity: 0; transform: scale(0.94); }
  66%, 96%  { opacity: 1; transform: scale(1); }
  100%      { opacity: 0; transform: scale(0.98); }
}

/* Connectors. A stub up from each device plus a bus across the tier, so
   the graph reads as a tree without a second coordinate system. */
.viz-rootcause__tier--core { position: relative; padding-top: 1.75rem; }

.viz-rootcause__tier--mid,
.viz-rootcause__tier--leaf { position: relative; padding-top: 1.6rem; }

.viz-rootcause__tier--mid::before,
.viz-rootcause__tier--leaf::before {
  content: "";
  position: absolute;
  top: 0.8rem;
  left: 2.125rem;
  right: 2.125rem;
  height: 1px;
  background: var(--viz-line);
}

.viz-rootcause__tier--mid .viz-rootcause__dev::before,
.viz-rootcause__tier--leaf .viz-rootcause__dev::before {
  content: "";
  position: absolute;
  top: -0.8rem;
  left: 50%;
  width: 1px;
  height: 0.8rem;
  background: var(--viz-line);
}

.viz-rootcause__tier--mid::after {
  content: "";
  position: absolute;
  top: -2rem;
  left: 50%;
  width: 1px;
  height: 2.8rem;
  background: var(--viz-line);
}

.viz-rootcause__tiers { position: relative; }
.viz-rootcause__stage { position: relative; }

.viz-rootcause__legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-4);
  margin-top: var(--sp-5);
  padding-top: var(--sp-4);
  border-top: 1px solid var(--viz-line);
  font-size: var(--fs-meta);
  color: var(--fg-3);
}

.viz-rootcause__legend span { display: inline-flex; align-items: center; gap: 0.4rem; }
.viz-rootcause__key { width: 0.625rem; height: 0.625rem; border-radius: 3px; border: 1.5px solid var(--warn); }
.viz-rootcause__key--cause { border-color: var(--viz-accent); }

@media (max-width: 560px) {
  .viz-rootcause__dev { width: 3.5rem; }
  .viz-rootcause__box { width: 2.25rem; height: 2rem; }
}


/* =====================================================================
   11  AI ASSISTANT - typed sequence
   Teaching: you can ask in plain English instead of writing a query.

   The type effect is an opaque cover sliding right in steps, so the loop
   is transform only. The cover's left edge is the caret.
   ===================================================================== */

.viz-assistant {
  --viz-ai-dur: 3.7s;
  --viz-type-steps: 46;
}

/* overflow:hidden here is load-bearing, not cosmetic. The question is one
   nowrap monospace line, and without it the line's min-content width
   propagates all the way up through the grid item and widens the page. */
.viz-assistant__q {
  display: flex;
  align-items: flex-start;
  gap: 0.625rem;
  margin: 0;
  min-width: 0;
  overflow: hidden;
}

.viz-assistant__q > .ico { color: var(--viz-accent); margin-top: 0.15rem; }

.viz-assistant__type {
  position: relative;
  display: inline-block;
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  line-height: 1.5;
  color: var(--fg);
  white-space: nowrap;
  max-width: 100%;
}

.viz-assistant__type::after {
  content: "";
  position: absolute;
  top: -0.15em;
  bottom: -0.15em;
  left: 0;
  right: -0.4em;
  z-index: 0;
  background: var(--surface);
  will-change: transform;
  animation: viz-type var(--viz-ai-dur) infinite;
}

/* The caret rides the same clock as the cover and blinks on its own. It is
   also what keeps this component alive through the long read of the answer. */
.viz-assistant__caret {
  position: absolute;
  z-index: 1;
  top: -0.15em;
  bottom: -0.15em;
  left: 0;
  right: -0.4em;
  border-left: 2px solid var(--viz-accent);
  pointer-events: none;
  will-change: transform;
  animation-name: viz-type, viz-caret;
  animation-duration: var(--viz-ai-dur), 0.9s;
  animation-timing-function: linear, steps(2, jump-none);
  animation-iteration-count: infinite, infinite;
}

@keyframes viz-caret {
  from { opacity: 1; }
  to   { opacity: 0; }
}

/* Three beats: the question types itself in 1.36s, the answer lands, the
   cover snaps back and the next question starts. */
@keyframes viz-type {
  0%        { transform: translate3d(0, 0, 0); animation-timing-function: steps(var(--viz-type-steps, 46), end); }
  34%       { transform: translate3d(100%, 0, 0); animation-timing-function: linear; }
  95%       { transform: translate3d(100%, 0, 0); }
  100%      { transform: translate3d(0, 0, 0); }
}

.viz-assistant__a {
  margin-top: var(--sp-5);
  padding-top: var(--sp-4);
  border-top: 1px solid var(--viz-line);
}

.viz-assistant__badge {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-size: var(--fs-micro);
  font-weight: 650;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--viz-accent);
}

.viz-assistant__lines {
  display: grid;
  gap: 0.625rem;
  margin: var(--sp-3) 0 0;
  padding: 0;
  list-style: none;
}

.viz-assistant__lines li {
  display: flex;
  align-items: center;
  gap: 0.625rem;
  font-size: var(--fs-sm);
  color: var(--fg-2);
  opacity: 0;
  will-change: transform, opacity;
  animation: viz-answer var(--viz-ai-dur) var(--ease-settle) infinite;
  /* Three lines at 0.14s. The last one is up by 1.72s and still up at
     3.88s, so it clears the 4s cycle. */
  animation-delay: calc(var(--i, 0) * 0.14s);
}

.viz-assistant__lines .ico { color: var(--viz-accent); }

@media (max-width: 720px) {
  .viz-assistant__type { font-size: var(--fs-meta); }
}

@keyframes viz-answer {
  0%, 36%   { opacity: 0; transform: translate3d(0, 0.5rem, 0); }
  43%, 90%  { opacity: 1; transform: translate3d(0, 0, 0); }
  97%, 100% { opacity: 0; transform: translate3d(0, 0.25rem, 0); }
}


/* =====================================================================
   12  PERIMETER - containment and circulation
   Teaching: nothing leaves your estate.
   ===================================================================== */

.viz-perimeter {
  --viz-orbit-dur: 3.8s;
  --viz-probe-dur: 3.8s;
}

.viz-perimeter__stage { padding: 0; }

.viz-perimeter__wall {
  container-type: inline-size;
  position: relative;
  display: grid;
  place-items: center;
  height: clamp(15rem, 24vw, 20rem);
  margin: var(--sp-4);
  border-radius: var(--r-md);
  border: 1.5px dashed var(--viz-accent);
}

.viz-perimeter__wall::after {
  content: "";
  position: absolute;
  inset: -3px;
  border-radius: var(--r-lg);
  border: 2px solid var(--viz-accent);
  opacity: 0;
  pointer-events: none;
  animation: viz-wall var(--viz-probe-dur) ease-out infinite;
}

@keyframes viz-wall {
  0%, 54%   { opacity: 0; }
  64%       { opacity: 0.8; }
  84%, 100% { opacity: 0; }
}

.viz-perimeter__label {
  position: absolute;
  top: -0.7rem;
  left: 1rem;
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  padding: 0 0.5rem;
  background: var(--surface);
  font-size: var(--fs-micro);
  font-weight: 650;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--viz-accent);
}

.viz-perimeter__core {
  position: relative;
  z-index: 2;
  display: grid;
  justify-items: center;
  gap: 0.35rem;
  padding: 0.75rem 1rem;
  border-radius: var(--r-md);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  color: var(--viz-accent);
  font-size: var(--fs-micro);
  font-weight: 650;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
}

.viz-perimeter__orbit {
  --viz-orbit-r: clamp(4.5rem, 12cqw, 8.25rem);
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
}

.viz-perimeter__sat {
  position: absolute;
  will-change: transform;
  animation: viz-orbit var(--viz-orbit-dur) linear infinite;
  animation-delay: calc(var(--i, 0) * var(--viz-orbit-dur) / -5);
}

.viz-perimeter__sat i {
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  border-radius: 50%;
  border: 1px solid var(--border-strong);
  background: var(--surface);
  color: var(--fg-2);
}

/* Orbit and upright in one list: the trailing rotate cancels the leading
   one, so the icon never tips while the satellite goes round. */
@keyframes viz-orbit {
  from { transform: rotate(0deg) translateX(var(--viz-orbit-r)) rotate(0deg); }
  to   { transform: rotate(360deg) translateX(var(--viz-orbit-r)) rotate(-360deg); }
}

/* One packet tries the wall and is turned back. Nothing crosses. */
.viz-perimeter__probe {
  position: absolute;
  z-index: 3;
  margin-left: 5.5rem;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: var(--warn);
  opacity: 0;
  animation: viz-probe var(--viz-probe-dur) cubic-bezier(0.4, 0, 0.3, 1) infinite;
}

/* Three beats: the probe leaves, it reaches the wall, it is turned back. */
@keyframes viz-probe {
  0%, 28%   { opacity: 0; transform: translate3d(0, 0, 0); }
  36%       { opacity: 0.9; transform: translate3d(0, 0, 0); }
  /* Stops short of the wall and of the label parked beside it. It used to
     reach 50cqw and render straight through the word "egress". */
  62%       { opacity: 0.9; transform: translate3d(calc(40cqw - 3.25rem), 0, 0); }
  78%       { opacity: 0.4; transform: translate3d(calc(26cqw - 3.25rem), 0, 0); }
  92%, 100% { opacity: 0; transform: translate3d(0, 0, 0); }
}

/* Parked in the top right corner, clear of the probe's travel line. */
.viz-perimeter__out {
  position: absolute;
  right: 0.75rem;
  top: 0.85rem;
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-size: var(--fs-micro);
  color: var(--fg-3);
  white-space: nowrap;
}

/* Narrow, the container query floors the radius at its clamp minimum while
   the core card keeps its full width, so a satellite passes behind the
   card. 5.75rem is the smallest radius that clears the measured core
   (129px wide) plus a 2rem satellite. */
@media (max-width: 640px) {
  .viz-perimeter__orbit { --viz-orbit-r: clamp(5.75rem, 34cqw, 8.25rem); }
  .viz-perimeter__core { padding: 0.6rem 0.7rem; }
}


/* =====================================================================
   13  VENDOR LATTICE - sequenced grid pulse
   Teaching: this works with what is already racked.
   ===================================================================== */

/* Ten cells at 0.1s is a 0.9s wave across the grid, so the last vendor
   has lit and settled by 1.6s and the loop restarts at 3s. */
.viz-lattice { --viz-lat-dur: 3s; --viz-lat-step: 0.1s; }

.viz-lattice__grid {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 0.5rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.viz-lattice__cell {
  position: relative;
  display: grid;
  place-items: center;
  padding: 0.9rem 0.4rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  font-size: var(--fs-micro);
  font-weight: 700;
  letter-spacing: 0.12em;
  color: var(--fg-2);
  text-align: center;
  white-space: nowrap;
  opacity: 0.45;
  will-change: transform, opacity;
  animation: viz-pulse var(--viz-lat-dur) ease-in-out infinite;
  animation-delay: calc(var(--i, 0) * var(--viz-lat-step));
}

.viz-lattice__cell::after {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  border: 1px solid var(--viz-accent);
  opacity: 0;
  animation: viz-pulse-rim var(--viz-lat-dur) ease-in-out infinite;
  animation-delay: inherit;
}

@keyframes viz-pulse {
  0%, 2%    { opacity: 0.45; transform: translate3d(0, 0, 0); }
  10%       { opacity: 1; transform: translate3d(0, -2px, 0); }
  34%, 100% { opacity: 0.45; transform: translate3d(0, 0, 0); }
}

@keyframes viz-pulse-rim {
  0%, 2%    { opacity: 0; }
  10%       { opacity: 1; }
  34%, 100% { opacity: 0; }
}

@media (max-width: 720px) {
  .viz-lattice__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

@media (max-width: 420px) {
  .viz-lattice__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}


/* =====================================================================
   14  LOG WATERFALL - continuous scroll with a pick-out
   Teaching: the signal is already in the logs, buried in volume.
   ===================================================================== */

/* The scroll has no beginning and no end, so it is allowed to stay slow.
   The pick-out is the beat, and it runs on its own 3.2s clock so a reader
   sees the platform find something inside the first four seconds. */
.viz-waterfall { --viz-wf-dur: 14s; --viz-wf-pick: 3.2s; }

.viz-waterfall__stage {
  padding: 0;
  height: clamp(15rem, 34vw, 22rem);
  -webkit-mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent);
  mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent);
}

.viz-waterfall__col {
  will-change: transform;
  animation: viz-scroll var(--viz-wf-dur) linear infinite;
}

@keyframes viz-scroll {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(0, -50%, 0); }
}

.viz-waterfall__set { margin: 0; padding: 0; list-style: none; }

.viz-waterfall__set li {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.28rem 0.9rem;
  border-left: 2px solid transparent;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  line-height: 1.5;
  color: var(--fg-3);
  opacity: 0.55;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  position: relative;
}

.viz-waterfall__set li time { flex: 0 0 auto; opacity: 0.7; }

.viz-waterfall__set li.is-pick {
  opacity: 1;
  color: var(--accent);
  border-left-color: var(--accent);
  background: var(--accent-soft);
}

.viz-waterfall__set li.is-pick .ico { color: var(--accent); }

/* The pick-out beat. Three stops, peaks at 0.58s, repeats every 3.2s, and
   is independent of where the column is in its slow pass. */
.viz-waterfall__set li.is-pick::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: var(--accent);
  opacity: 0.08;
  animation: viz-pick var(--viz-wf-pick, 3.2s) ease-in-out infinite;
}

@keyframes viz-pick {
  0%, 100% { opacity: 0.06; }
  18%      { opacity: 0.30; }
  52%      { opacity: 0.10; }
}

/* A fixed reading band. The lines move, the band does not. */
.viz-waterfall__reticle {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  height: 2.25rem;
  pointer-events: none;
  border-top: 1px solid var(--border-strong);
  border-bottom: 1px solid var(--border-strong);
}

.viz-waterfall__reticle span {
  position: absolute;
  right: 0.5rem;
  top: 50%;
  transform: translateY(-50%);
  padding: 0.1rem 0.4rem;
  border-radius: var(--r-xs);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  font-size: 0.625rem;
  font-weight: 700;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--fg-3);
}

/* Wide, the log lines end well before the badge. Narrow, they run the full
   width and the badge lands mid-word. The row is a flex box, so clipping it
   alone does nothing: the message span has to be allowed to shrink before
   text-overflow can cut it. Padding then reserves the badge its own lane. */
@media (max-width: 640px) {
  .viz-waterfall__set li { padding-right: 4.5rem; }
  .viz-waterfall__set li span {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
  }
}


/* =====================================================================
   15  SPOTLIGHT PLATE - cursor tracked reveal
   Decorative, and the only component besides the field allowed to be.
   Ported from the reference copper plate to PulsarOps blue.
   ===================================================================== */

.viz-spot {
  --spot-x: 50%;
  --spot-y: 40%;
  position: relative;
  isolation: isolate;
}

.viz-spot::before,
.viz-spot::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-position: 0 0;
  background-size: 18px 18px;
  border-radius: inherit;
  transition: opacity 0.35s var(--ease-out);
}

.viz-spot::before {
  opacity: 0.5;
  background-image: radial-gradient(circle, rgba(139, 190, 248, 0.20) 1.4px, transparent 1.9px);
}

.viz-spot::after {
  opacity: 0;
  background-image: radial-gradient(circle, rgba(139, 190, 248, 0.95) 1.5px, transparent 2px);
  -webkit-mask-image: radial-gradient(14rem circle at var(--spot-x) var(--spot-y), #000 0%, rgba(0,0,0,0.45) 42%, transparent 70%);
  mask-image: radial-gradient(14rem circle at var(--spot-x) var(--spot-y), #000 0%, rgba(0,0,0,0.45) 42%, transparent 70%);
}

.section--light .viz-spot::before {
  background-image: radial-gradient(circle, rgba(10, 20, 43, 0.14) 1.4px, transparent 1.9px);
}

.section--light .viz-spot::after {
  background-image: radial-gradient(circle, rgba(12, 62, 138, 0.85) 1.5px, transparent 2px);
}

/* A .cta-band is a dark plate even when it sits inside a light section, so
   it keeps the bright grid. Without this the light-context rule above paints
   near-black dots on near-black navy and the plate reads as dead. */
.section--light .cta-band.viz-spot::before {
  background-image: radial-gradient(circle, rgba(139, 190, 248, 0.20) 1.4px, transparent 1.9px);
}

.section--light .cta-band.viz-spot::after {
  background-image: radial-gradient(circle, rgba(139, 190, 248, 0.95) 1.5px, transparent 2px);
}

.viz-spot:hover::before,
.viz-spot:focus-within::before { opacity: 0.85; }

.viz-spot:hover::after,
.viz-spot:focus-within::after { opacity: 1; }

.viz-spot > * { position: relative; z-index: 1; }


/* =====================================================================
   16  REDUCED MOTION END STATES

   site.css already collapses every animation to 0.01ms with one
   iteration, which lands each loop on its 100% keyframe. For most of
   these that is the reset frame, which would read as broken. So each
   component gets an explicit resolved state here instead: the picture
   you would see if you watched the whole loop and it stopped on the
   moment that carries the meaning.
   ===================================================================== */

@media (prefers-reduced-motion: reduce) {

  [data-viz] *,
  [data-viz] *::before,
  [data-viz] *::after,
  .viz-field__glow,
  .viz-marquee__track {
    animation: none !important;
  }

  /* Field: the glow simply sits still. */
  .viz-field__glow { transform: translate3d(0, 0, 0) scale(1.06) !important; }

  /* Headline: fully revealed, no transition. */
  .viz-split__c { opacity: 1 !important; transform: none !important; transition: none !important; }

  /* Marquee: the first screenful, static, and it scrolls by hand. */
  .viz-marquee { overflow-x: auto; }
  .viz-marquee__track { transform: none !important; }

  /* Ingest: every packet has arrived at the collector. */
  .viz-ingest__run { transform: translate3d(calc(100% - var(--viz-pkt) * 1.7), 0, 0) !important; }
  .viz-ingest__pkt { opacity: 1 !important; }
  .viz-ingest__well::after { opacity: 0.85 !important; transform: scale(1.1) !important; }

  /* Correlation: the clustered, linked state. That is the point. */
  .viz-correlate__dot { transform: translate(-50%, -50%) !important; opacity: 1 !important; }
  .viz-correlate__link { opacity: 0.55 !important; }
  .viz-correlate__ring { opacity: 0.4 !important; transform: scale(1) !important; }
  .viz-correlate__note { opacity: 1 !important; }

  /* Pipeline: all three stages lit, payload parked at insights. */
  .viz-pipeline__stage::before { opacity: 1 !important; }
  .viz-pipeline__payload { opacity: 1 !important; }
  .viz-pipeline__run { transform: translate3d(calc(83.333% - var(--viz-pay) / 2), 0, 0) !important; opacity: 1 !important; }

  /* Collapse: rows still legible, incident card present. Both halves of
     the sentence, side by side, rather than the after with no before. */
  .viz-collapse__row { opacity: 1 !important; transform: none !important; }
  .viz-collapse__live { opacity: 1 !important; transform: none !important; }
  .viz-collapse__arrow { opacity: 1 !important; transform: none !important; }
  .viz-collapse__card { opacity: 1 !important; transform: none !important; }

  /* Race: both bars at their finished length. */
  .viz-race__fill { transform: scaleX(1) !important; }
  .viz-race__row--fast .viz-race__fill { transform: scaleX(0.125) !important; }
  .viz-race__flag { opacity: 1 !important; transform: translate(-50%, -50%) !important; }

  /* Root cause: the fault has spread and the cause is named. */
  .viz-rootcause__ring { opacity: 0.55 !important; transform: scale(1) !important; }
  .viz-rootcause__blame { opacity: 1 !important; transform: scale(1) !important; }
  .viz-rootcause__tagline { opacity: 1 !important; transform: translate(-50%, 0) !important; }

  /* Assistant: question typed in full, answer shown. */
  .viz-assistant__type::after { transform: translate3d(100%, 0, 0) !important; }
  .viz-assistant__caret { transform: translate3d(100%, 0, 0) !important; opacity: 1 !important; }
  .viz-assistant__lines li { opacity: 1 !important; transform: none !important; }

  /* Perimeter: everything at rest inside, wall intact. */
  .viz-perimeter__sat {
    transform: rotate(calc(var(--i, 0) * 72deg))
               translateX(var(--viz-orbit-r, 7rem))
               rotate(calc(var(--i, 0) * -72deg)) !important;
  }
  .viz-perimeter__probe { opacity: 0 !important; }
  .viz-perimeter__wall::after { opacity: 0.35 !important; }

  /* Lattice: every vendor lit, none singled out. */
  .viz-lattice__cell { opacity: 1 !important; transform: none !important; }
  .viz-lattice__cell::after { opacity: 0 !important; }

  /* Waterfall: static column, picked lines still picked, scroll by hand. */
  .viz-waterfall__stage { overflow-y: auto; }
  .viz-waterfall__col { transform: none !important; }
  .viz-waterfall__set li.is-pick::after { opacity: 0.16 !important; }

  /* Spotlight: a fixed reveal instead of a cursor-tracked one. */
  .viz-spot::after {
    opacity: 0.9;
    -webkit-mask-image: radial-gradient(16rem circle at 50% 40%, #000 0%, rgba(0,0,0,0.45) 45%, transparent 72%);
    mask-image: radial-gradient(16rem circle at 50% 40%, #000 0%, rgba(0,0,0,0.45) 45%, transparent 72%);
    transition: none;
  }
}


/* Frostier chrome preference: drop the two atmospheric layers. */
@media (prefers-reduced-transparency: reduce) {
  .viz-field { display: none; }
  .viz-spot::after { display: none; }
}
