/* =====================================================================
   PulsarOps AI - motion-b.css
   Eight teaching visualizations, batch B.

   Loads AFTER site.css and AFTER motion.css. It reuses the shell that
   motion.css owns (.viz, .viz__stage, .viz__cap, .viz__label, the
   [data-viz] gate and the [data-viz-pause] hover pause) and adds
   nothing to it. Every colour comes from the contextual tokens site.css
   sets on .section--dark / .section--light, so a component recolours
   itself when you move it between bands.

   House technique, unchanged from motion.css:
     - transform and opacity only, never width / top / left / filter
     - stagger comes from animation-delay computed off --i / --s / --a
     - travel distances come from custom properties or percentages of
       the element's own box, so a resize never breaks a loop
     - every loop finishes its whole story in 4 seconds or less
     - every component has a resolved prefers-reduced-motion end state

   All keyframes here are prefixed vb- so nothing collides with the
   viz- keyframes motion.css already owns.

   Contents
   B0  Batch defaults
   B1  NORMALIZE          stacking and alignment          3.4s
   B2  SIEVE              filtering and fall-through      3.2s
   B3  TRIAGE BRANCH      branching decision tree         3.4s
   B4  SWEEP              radial sweep                    3.4s
   B5  EVIDENCE ASSEMBLY  assembly and construction       3.4s
   B6  ZIP                interleave                      3.4s
   B7  HOP TRACE          path tracing                    3.6s
   B8  TALLY              counter settle                  3.2s
   B9  Reduced motion end states
   ===================================================================== */


/* =====================================================================
   B0  BATCH DEFAULTS
   One ceiling property so a page can slow the whole batch down for a
   screen recording without touching eight separate numbers.
   ===================================================================== */

.viz-normalize,
.viz-sieve,
.viz-branch,
.viz-sweep,
.viz-evidence,
.viz-zip,
.viz-hop,
.viz-tally {
  --viz-b-warn: var(--warn, #F59E0B);
  --viz-b-ease: cubic-bezier(0.16, 1, 0.3, 1);
  --viz-b-mono: var(--font-mono);
}


/* =====================================================================
   B1  NORMALIZE - stacking and alignment
   Teaching: ten vendors write the same event ten different ways.
   Five rows arrive ragged, each pushed off the grid by its own --ox /
   --oy, then slide onto one shared schema. The column rule draws itself
   the moment the last row lands, which is the "one schema" beat.
   ===================================================================== */

.viz-normalize {
  --viz-nz-dur: 3.4s;
  --viz-nz-step: 0.1s;
  --viz-nz-cols: 4.9rem minmax(0, 1fr) 4.9rem minmax(0, 1.3fr);
}

.viz-normalize__stage { display: grid; gap: var(--sp-4); }

.viz-normalize__head {
  margin: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
}

.viz-normalize__head .ico { color: var(--viz-accent); }

/* The schema header. Its underline is the alignment guide, and it is
   the only part of this component that is not a log row. */
.viz-normalize__cols {
  position: relative;
  display: grid;
  grid-template-columns: var(--viz-nz-cols);
  gap: 0.75rem;
  margin: 0 0 0.5rem;
  padding: 0 0 0.4rem;
  list-style: none;
  font-size: var(--fs-micro);
  font-weight: 650;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--fg-3);
}

.viz-normalize__cols::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--viz-accent);
  transform: scaleX(0);
  transform-origin: left center;
  will-change: transform, opacity;
  animation: vb-nz-rule var(--viz-nz-dur) var(--viz-b-ease) infinite;
  animation-delay: calc(4 * var(--viz-nz-step));
}

.viz-normalize__rows { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.3rem; }

.viz-normalize__row {
  display: grid;
  grid-template-columns: var(--viz-nz-cols);
  align-items: center;
  gap: 0.75rem;
  min-width: 0;
  padding: 0.34rem 0.5rem;
  border-radius: var(--r-xs);
  background: var(--surface-2);
  font-size: var(--fs-meta);
  color: var(--fg-2);
  white-space: nowrap;
  will-change: transform, opacity;
  animation:
    vb-nz-align var(--viz-nz-dur) var(--viz-b-ease) infinite,
    vb-nz-in var(--viz-nz-dur) linear infinite;
  animation-delay: calc(var(--i, 0) * var(--viz-nz-step));
}

.viz-normalize__row > * { overflow: hidden; text-overflow: ellipsis; }
.viz-normalize__row time { font-family: var(--viz-b-mono); color: var(--fg-3); }
.viz-normalize__row b { color: var(--fg); font-weight: 600; }

.viz-normalize__row em {
  font-style: normal;
  font-size: var(--fs-micro);
  font-weight: 700;
  letter-spacing: 0.1em;
  color: var(--viz-accent);
}

@keyframes vb-nz-align {
  0%, 10%  { transform: translate3d(var(--ox, 0), var(--oy, 0), 0); }
  48%, 88% { transform: translate3d(0, 0, 0); }
  100%     { transform: translate3d(var(--ox, 0), var(--oy, 0), 0); }
}

@keyframes vb-nz-in {
  0%       { opacity: 0; }
  14%      { opacity: 0.4; }
  48%, 86% { opacity: 1; }
  91%, 100% { opacity: 0; }
}

@keyframes vb-nz-rule {
  0%, 20%  { transform: scaleX(0); opacity: 0; }
  46%      { opacity: 1; }
  58%, 86% { transform: scaleX(1); opacity: 1; }
  92%, 100% { transform: scaleX(1); opacity: 0; }
}

@media (max-width: 720px) {
  .viz-normalize { --viz-nz-cols: 4.4rem minmax(0, 1fr) 4.4rem; }
  .viz-normalize__row span,
  .viz-normalize__cols li:last-child { display: none; }
}


/* =====================================================================
   B2  SIEVE - filtering and fall-through
   Teaching: most events are noise, a few are the incident.
   Eight events fall. Six stop on the mesh and go grey. Two carry
   .viz-sieve__ev--pass, keep falling, and land in the tray. The split
   happens at one shared depth, so the mesh reads as the thing that
   decided it.
   ===================================================================== */

.viz-sieve {
  --viz-sv-dur: 3.2s;
  --viz-sv-step: 0.09s;
  --viz-sv-h: clamp(6rem, 15vw, 8rem);
  --viz-sv-tray: clamp(2.6rem, 6vw, 3.6rem);
  /* the catch line is the top of the mesh, less the dot's own height, so a
     caught dot rests on the mesh instead of sitting inside it */
  --viz-sv-catch: calc(var(--viz-sv-h) - 0.62rem);
  --viz-sv-drop: calc(var(--viz-sv-h) + 1.35rem + var(--viz-sv-tray) / 2 - 0.35rem);
}

.viz-sieve__stage { display: grid; gap: 0; }

/* The falling layer paints above the mesh and above the tray. Without the
   z-index the tray is later in the DOM, so it covers the two events that
   fell through, which is exactly the half of the story worth seeing. */
.viz-sieve__sky {
  position: relative;
  z-index: 2;
  height: var(--viz-sv-h);
  pointer-events: none;
}

.viz-sieve__ev {
  position: absolute;
  top: 0;
  left: var(--x, 50%);
  width: 0.55rem;
  height: 0.55rem;
  margin-left: -0.275rem;
  border-radius: 50%;
  background: var(--fg-3);
  will-change: transform, opacity;
  animation: vb-sv-catch var(--viz-sv-dur) cubic-bezier(0.4, 0, 0.7, 1) infinite;
  animation-delay: calc(var(--i, 0) * var(--viz-sv-step));
}

.viz-sieve__ev--pass {
  width: 0.7rem;
  height: 0.7rem;
  margin-left: -0.35rem;
  background: var(--viz-accent);
  animation-name: vb-sv-pass;
}

@keyframes vb-sv-catch {
  0%       { transform: translate3d(0, 0, 0) scale(1); opacity: 0; }
  8%       { opacity: 1; }
  44%      { transform: translate3d(0, var(--viz-sv-catch), 0) scale(1); opacity: 1; }
  56%, 86% { transform: translate3d(0, var(--viz-sv-catch), 0) scale(0.72); opacity: 0.28; }
  94%, 100% { transform: translate3d(0, var(--viz-sv-catch), 0) scale(0.72); opacity: 0; }
}

@keyframes vb-sv-pass {
  0%       { transform: translate3d(0, 0, 0) scale(1); opacity: 0; }
  8%       { opacity: 1; }
  44%      { transform: translate3d(0, var(--viz-sv-catch), 0) scale(1); opacity: 1; }
  66%, 86% { transform: translate3d(0, var(--viz-sv-drop), 0) scale(1); opacity: 1; }
  94%, 100% { transform: translate3d(0, var(--viz-sv-drop), 0) scale(1); opacity: 0; }
}

/* The mesh. Static pattern, animated rim, so the sieve itself has a
   heartbeat during the hold at the end of the loop. */
.viz-sieve__mesh {
  position: relative;
  z-index: 1;
  height: 1.35rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-top: 1px solid var(--viz-line);
  border-bottom: 1px solid var(--viz-line);
  background:
    repeating-linear-gradient(90deg,
      var(--viz-line) 0 1px, transparent 1px 0.5rem);
}

.viz-sieve__mesh .viz__label {
  position: relative;
  padding: 0 0.5rem;
  background: var(--surface);
}

.viz-sieve__mesh .ico { color: var(--viz-accent); }

.viz-sieve__mesh::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: -1px;
  height: 1px;
  background: var(--viz-accent);
  opacity: 0;
  animation: vb-sv-mesh var(--viz-sv-dur) ease-out infinite;
}

@keyframes vb-sv-mesh {
  0%, 36%  { opacity: 0; }
  50%      { opacity: 0.9; }
  70%, 100% { opacity: 0.14; }
}

.viz-sieve__tray {
  position: relative;
  margin-top: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  min-height: var(--viz-sv-tray);
  border-radius: var(--r-sm);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  font-size: var(--fs-meta);
  color: var(--fg-2);
}

.viz-sieve__tray b { color: var(--fg); }
.viz-sieve__tray .ico { color: var(--viz-accent); }

.viz-sieve__tray::after {
  content: "";
  position: absolute;
  inset: -0.28rem;
  border-radius: var(--r-md);
  border: 1px solid var(--viz-accent);
  opacity: 0;
  animation: vb-sv-tray var(--viz-sv-dur) ease-out infinite;
}

@keyframes vb-sv-tray {
  0%, 60%  { transform: scale(1); opacity: 0.12; }
  72%      { transform: scale(1.03); opacity: 0.9; }
  88%, 100% { transform: scale(1); opacity: 0.12; }
}


/* =====================================================================
   B3  TRIAGE BRANCH - branching decision tree
   Teaching: triage is a series of questions, answered in order.
   Nodes sit at --nx / --ny percentages of the stage. The SVG overlay
   uses the same 0-100 coordinate space with preserveAspectRatio none,
   so a wire always ends on a node no matter how wide the column is.
   ===================================================================== */

.viz-branch {
  --viz-br-dur: 3.4s;
  --viz-br-step: 0.18s;
}

/* The field, not the stage, is the coordinate space. The stage keeps
   its padding, so an absolutely positioned node measured in percent and
   an SVG line measured in the same percent would not agree if they were
   hung off different boxes. */
.viz-branch__field {
  position: relative;
  height: clamp(12rem, 30vw, 15rem);
}

.viz-branch__wires {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}

.viz-branch__wire {
  fill: none;
  stroke: var(--viz-line);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

.viz-branch__wire--lit {
  stroke: var(--viz-accent);
  stroke-width: 1.5;
  opacity: 0;
  animation: vb-br-wire var(--viz-br-dur) var(--viz-b-ease) infinite;
  animation-delay: calc(var(--s, 0) * var(--viz-br-step));
}

@keyframes vb-br-wire {
  0%, 8%   { opacity: 0; }
  22%, 92% { opacity: 1; }
  99%, 100% { opacity: 0; }
}

.viz-branch__node {
  position: absolute;
  left: var(--nx, 50%);
  top: var(--ny, 50%);
  transform: translate(-50%, -50%);
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.34rem 0.6rem;
  border-radius: var(--r-full);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: var(--tr-meta);
  color: var(--fg-2);
  white-space: nowrap;
}

/* Nodes are positioned by the outer box and animated on the inner
   element, so the position transform and the loop transform never
   fight over the same property. */
.viz-branch__node > i {
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  border: 1px solid var(--viz-accent);
  opacity: 0;
}

.viz-branch__node--on {
  color: var(--fg);
  opacity: 0.4;
  will-change: opacity;
  animation: vb-br-on var(--viz-br-dur) var(--viz-b-ease) infinite;
  animation-delay: calc(var(--s, 0) * var(--viz-br-step));
}

.viz-branch__node--on > i {
  animation: vb-br-rim var(--viz-br-dur) var(--viz-b-ease) infinite;
  animation-delay: inherit;
}

.viz-branch__node--off {
  opacity: 0.5;
  animation: vb-br-off var(--viz-br-dur) var(--viz-b-ease) infinite;
  animation-delay: calc(var(--s, 0) * var(--viz-br-step));
}

.viz-branch__node--root { color: var(--fg); opacity: 1; }
.viz-branch__node--root > i { opacity: 1; }

.viz-branch__node .ico { color: var(--viz-accent); }
.viz-branch__node--off .ico { color: inherit; }

@keyframes vb-br-on  { 0%, 8% { opacity: 0.4; } 24%, 92% { opacity: 1; } 99%, 100% { opacity: 0.4; } }
@keyframes vb-br-off { 0%, 8% { opacity: 0.5; } 30%, 92% { opacity: 0.16; } 99%, 100% { opacity: 0.5; } }
@keyframes vb-br-rim { 0%, 10% { opacity: 0; } 26%, 90% { opacity: 1; } 98%, 100% { opacity: 0; } }

/* The seed pulses the whole time, so the component never rests. */
.viz-branch__seed {
  position: absolute;
  inset: -0.3rem;
  border-radius: inherit;
  border: 1px solid var(--viz-accent);
  opacity: 0.2;
  animation: vb-br-seed 1.5s ease-in-out infinite;
}

@keyframes vb-br-seed {
  0%, 100% { transform: scale(1); opacity: 0.16; }
  50%      { transform: scale(1.09); opacity: 0.6; }
}

/* The pill is the animated child, not the positioned parent. Put the fill on
   the parent and it shows as a blank blue slab for the whole delay. */
.viz-branch__flag {
  position: absolute;
  left: var(--nx, 50%);
  top: var(--ny, 50%);
  transform: translate(-50%, calc(-50% + 1.6rem));
  pointer-events: none;
}

.viz-branch__flag > span {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  padding: 0.15rem 0.5rem;
  border-radius: var(--r-xs);
  background: var(--viz-accent);
  color: var(--bg);
  font-size: var(--fs-micro);
  font-weight: 700;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  white-space: nowrap;
  opacity: 0;
  will-change: transform, opacity;
  animation: vb-br-flag var(--viz-br-dur) var(--viz-b-ease) infinite;
  animation-delay: calc(2 * var(--viz-br-step));
}

.viz-branch__flag .ico { color: var(--bg); }

/* Fades in on the spot. It used to rise 0.3rem into place, which put it
   through the pill above it for the first fifth of the loop. */
@keyframes vb-br-flag {
  0%, 6%    { opacity: 0; transform: scale(0.96); }
  22%, 84%  { opacity: 1; transform: scale(1); }
  92%, 100% { opacity: 0; transform: scale(0.96); }
}

@media (max-width: 640px) {
  .viz-branch__node { font-size: 0.625rem; padding: 0.28rem 0.45rem; }
  .viz-branch__node .ico { --ico-size: 0.75rem; }
}


/* =====================================================================
   B4  SWEEP - radial sweep
   Teaching: every device in the estate reports, not a sampled subset.
   One beam, one revolution per loop. Each device sits at --a degrees
   and its ping delay is that angle as a fraction of the loop, so the
   light is always exactly under the beam.
   ===================================================================== */

.viz-sweep {
  --viz-sweep-dur: 3.4s;
  --viz-sweep-d: clamp(11rem, 28vw, 15rem);
  --viz-sweep-r: calc(var(--viz-sweep-d) / 2 - 1.5rem);
}

.viz-sweep__stage { display: grid; justify-items: center; gap: var(--sp-4); }

.viz-sweep__dial {
  position: relative;
  width: var(--viz-sweep-d);
  height: var(--viz-sweep-d);
  border-radius: 50%;
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
}

.viz-sweep__ring {
  position: absolute;
  inset: 18%;
  border-radius: 50%;
  border: 1px solid var(--viz-line);
  opacity: 0.7;
}

.viz-sweep__ring--in { inset: 36%; }

.viz-sweep__beam {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  overflow: hidden;
  background:
    conic-gradient(from 0deg, transparent 0deg, transparent 336deg, var(--accent-soft) 359deg, transparent 360deg),
    conic-gradient(from 0deg, transparent 0deg, transparent 316deg, var(--accent-soft) 360deg),
    conic-gradient(from 0deg, transparent 0deg, transparent 288deg, var(--accent-soft) 360deg);
  will-change: transform;
  animation: vb-sw-turn var(--viz-sweep-dur) linear infinite;
}

/* The leading edge of the beam, at the wedge's 0deg. */
.viz-sweep__beam::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 0;
  width: 1.5px;
  height: 50%;
  margin-left: -0.75px;
  background: linear-gradient(180deg, var(--viz-accent), transparent);
}

@keyframes vb-sw-turn {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

.viz-sweep__hub {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 2.5rem;
  height: 2.5rem;
  margin: -1.25rem 0 0 -1.25rem;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 1px solid var(--viz-line);
  background: var(--surface);
  color: var(--viz-accent);
}

.viz-sweep__dev {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 2rem;
  height: 2rem;
  margin: -1rem 0 0 -1rem;
  transform:
    rotate(calc(var(--a, 0) * 1deg))
    translateY(calc(var(--viz-sweep-r) * -1))
    rotate(calc(var(--a, 0) * -1deg));
}

.viz-sweep__dev > i {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  border-radius: var(--r-sm);
  border: 1px solid var(--viz-line);
  background: var(--surface);
  color: var(--fg-2);
  opacity: 0.3;
  will-change: transform, opacity;
  animation: vb-sw-ping var(--viz-sweep-dur) ease-out infinite;
  /* Negative phase offset. Every device is already inside the loop at t=0,
     so the beam completes a full pass in one --viz-sweep-dur. */
  animation-delay: calc((var(--a, 0) / 360 - 1) * var(--viz-sweep-dur));
}

@keyframes vb-sw-ping {
  0%       { opacity: 1; transform: scale(1.14); }
  16%      { opacity: 0.92; transform: scale(1); }
  60%, 86% { opacity: 0.3; transform: scale(1); }
  100%     { opacity: 1; transform: scale(1.14); }
}

.viz-sweep__legend {
  margin: 0;
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-size: var(--fs-meta);
  color: var(--fg-3);
}

.viz-sweep__legend .ico { color: var(--viz-accent); }


/* =====================================================================
   B5  EVIDENCE ASSEMBLY - assembly and construction
   Teaching: root cause arrives with its evidence attached.
   Four fragments fly in from --fx / --fy, which are percentages of each
   fragment's own box, and lock into the card. The seal lands last.
   ===================================================================== */

.viz-evidence {
  --viz-ev-dur: 3.4s;
  --viz-ev-step: 0.14s;
}

.viz-evidence__stage { display: grid; place-items: center; }

.viz-evidence__card {
  position: relative;
  width: min(100%, 26rem);
  display: grid;
  gap: 0.7rem;
  padding: var(--sp-4);
  border-radius: var(--r-md);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
}

.viz-evidence__card::after {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  border: 1px solid var(--viz-accent);
  opacity: 0;
  animation: vb-ev-rim var(--viz-ev-dur) var(--viz-b-ease) infinite;
}

@keyframes vb-ev-rim {
  0%, 46%  { opacity: 0; }
  64%, 84% { opacity: 0.75; }
  94%, 100% { opacity: 0; }
}

.viz-evidence__head {
  margin: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
}

.viz-evidence__head .ico { color: var(--viz-accent); }
.viz-evidence__seal .ico { color: var(--bg); }

.viz-evidence__seal {
  position: relative;
  display: grid;
  place-items: center;
  width: 1.7rem;
  height: 1.7rem;
  border-radius: 50%;
  background: var(--viz-accent);
  color: var(--bg);
  opacity: 0;
  will-change: transform, opacity;
  animation: vb-ev-seal var(--viz-ev-dur) var(--viz-b-ease) infinite;
}

@keyframes vb-ev-seal {
  0%, 52%  { opacity: 0; transform: scale(0.5); }
  66%, 84% { opacity: 1; transform: scale(1); }
  92%, 100% { opacity: 0; transform: scale(0.5); }
}

.viz-evidence__title {
  margin: 0;
  font-family: var(--viz-b-mono);
  font-size: var(--fs-sm);
  color: var(--fg);
}

.viz-evidence__sub {
  margin: 0;
  font-size: var(--fs-meta);
  color: var(--fg-3);
}

.viz-evidence__frags {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.4rem;
  margin: 0.2rem 0 0;
  padding: 0;
  list-style: none;
}

.viz-evidence__frags li {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  min-width: 0;
  padding: 0.4rem 0.5rem;
  border-radius: var(--r-xs);
  border: 1px solid var(--viz-line);
  background: var(--surface);
  font-size: var(--fs-micro);
  color: var(--fg-2);
  white-space: nowrap;
  overflow: hidden;
  opacity: 0;
  will-change: transform, opacity;
  animation: vb-ev-frag var(--viz-ev-dur) var(--viz-b-ease) infinite;
  animation-delay: calc(var(--i, 0) * var(--viz-ev-step));
}

.viz-evidence__frags .ico { color: var(--viz-accent); }

@keyframes vb-ev-frag {
  0%, 5%   { opacity: 0; transform: translate3d(var(--fx, 0), var(--fy, 0), 0) scale(0.8); }
  20%      { opacity: 1; }
  46%, 84% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
  90%, 100% { opacity: 0; transform: translate3d(var(--fx, 0), var(--fy, 0), 0) scale(0.8); }
}

@media (max-width: 520px) {
  .viz-evidence__frags { grid-template-columns: minmax(0, 1fr); }
}


/* =====================================================================
   B6  ZIP - interleave
   Teaching: two device timelines become one ordered incident timeline.
   Every row knows its final index --i and its index within its own
   stream --j. The start offset is the difference between the two,
   times the row pitch, so the rows really do un-interleave rather than
   just slide in from the side.
   ===================================================================== */

.viz-zip {
  /* 3.4s ran the un-interleave faster than the eye can follow two streams
     at once. At 7s each beat gets its own moment: read them apart, watch
     them merge, read the single order. */
  --viz-zip-dur: 7s;
  --viz-zip-step: 0.14s;
  --viz-zip-pitch: 1.9rem;
  --viz-zip-w: 47%;
  --viz-zip-dx: 52%;
}

.viz-zip__stage { display: grid; gap: var(--sp-3); }

.viz-zip__heads { position: relative; margin: 0; height: 1.2rem; }

.viz-zip__head {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font-size: var(--fs-micro);
  font-weight: 650;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--fg-3);
  white-space: nowrap;
}

.viz-zip__head .ico { color: var(--viz-accent); }
.viz-zip__head--a { left: 25.6%; }
.viz-zip__head--b { left: 74.4%; }
.viz-zip__head--one { left: 50%; color: var(--fg); opacity: 0; }

.viz-zip__head--a,
.viz-zip__head--b { animation: vb-zip-head-out var(--viz-zip-dur) var(--viz-b-ease) infinite; }
.viz-zip__head--one { animation: vb-zip-head-in var(--viz-zip-dur) var(--viz-b-ease) infinite; }

@keyframes vb-zip-head-out { 0%, 26% { opacity: 1; } 44%, 88% { opacity: 0; } 100% { opacity: 1; } }
@keyframes vb-zip-head-in  { 0%, 40% { opacity: 0; } 56%, 88% { opacity: 1; } 100% { opacity: 0; } }

.viz-zip__rows {
  position: relative;
  display: grid;
  grid-auto-rows: var(--viz-zip-pitch);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The single rail the two streams end up on. */
.viz-zip__rows::before {
  content: "";
  position: absolute;
  left: 50%;
  top: -0.2rem;
  bottom: -0.2rem;
  width: 1px;
  margin-left: -0.5px;
  background: var(--viz-accent);
  transform: scaleY(0);
  transform-origin: top center;
  animation: vb-zip-rail var(--viz-zip-dur) var(--viz-b-ease) infinite;
}

@keyframes vb-zip-rail {
  0%, 30%  { transform: scaleY(0); opacity: 0; }
  46%      { opacity: 0.75; }
  54%, 86% { transform: scaleY(1); opacity: 0.75; }
  94%, 100% { transform: scaleY(1); opacity: 0; }
}

.viz-zip__row {
  position: relative;
  justify-self: center;
  width: var(--viz-zip-w);
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
  padding: 0.2rem 0.5rem;
  border-radius: var(--r-xs);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  font-size: var(--fs-micro);
  color: var(--fg-2);
  white-space: nowrap;
  will-change: transform, opacity;
  animation:
    vb-zip var(--viz-zip-dur) var(--viz-b-ease) infinite,
    vb-zip-in var(--viz-zip-dur) linear infinite;
  animation-delay: calc(var(--i, 0) * var(--viz-zip-step));
}

.viz-zip__row time { font-family: var(--viz-b-mono); color: var(--fg-3); }
.viz-zip__row span { overflow: hidden; text-overflow: ellipsis; }
.viz-zip__row--b { border-color: var(--border-strong); }

@keyframes vb-zip {
  0%, 14% {
    transform: translate3d(
      calc(var(--side, 0) * var(--viz-zip-dx)),
      calc((var(--j, 0) - var(--i, 0)) * var(--viz-zip-pitch)), 0);
  }
  50%, 88% { transform: translate3d(0, 0, 0); }
  100% {
    transform: translate3d(
      calc(var(--side, 0) * var(--viz-zip-dx)),
      calc((var(--j, 0) - var(--i, 0)) * var(--viz-zip-pitch)), 0);
  }
}

@keyframes vb-zip-in {
  0%       { opacity: 0; }
  10%, 88% { opacity: 1; }
  95%, 100% { opacity: 0; }
}

@media (max-width: 640px) {
  /* Two labels will not sit side by side on a phone with their icons, and
     the icons are the first thing that can go without losing the reading. */
  .viz-zip { --viz-zip-w: 66%; --viz-zip-dx: 38%; }
  .viz-zip__head { font-size: 0.625rem; letter-spacing: 0.08em; }
  .viz-zip__head .ico { display: none; }
  .viz-zip__head--a { left: 26%; }
  .viz-zip__head--b { left: 74%; }
}


/* =====================================================================
   B7  HOP TRACE - path tracing
   Teaching: the fault is on one hop, not the whole path.
   Nothing travels the route. The route itself draws, segment by
   segment, then every hop but one falls back and the bad hop keeps its
   ring. Segments are scaleX on an overlay, so the fill is a transform.
   ===================================================================== */

.viz-hop {
  --viz-hop-dur: 3.5s;
  --viz-hop-step: 0.12s;
}

.viz-hop__stage { display: grid; gap: var(--sp-4); }

.viz-hop__route {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  align-items: start;
  margin: 0;
  padding: 0;
  list-style: none;
  min-width: 0;
}

.viz-hop__hop {
  position: relative;
  display: grid;
  justify-items: center;
  gap: 0.45rem;
  min-width: 0;
}

/* Runs from the previous hop's centre to this one's. Equal grid
   columns are what make -50% land exactly on the neighbour. */
.viz-hop__seg {
  position: absolute;
  top: 1.1rem;
  left: -50%;
  right: 50%;
  height: 2px;
  margin-top: -1px;
  background: var(--viz-line);
}

.viz-hop__seg::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--viz-accent);
  transform: scaleX(0);
  transform-origin: left center;
  will-change: transform;
  animation: vb-hop-trace var(--viz-hop-dur) var(--viz-b-ease) infinite;
  animation-delay: calc(var(--i, 0) * var(--viz-hop-step));
}

@keyframes vb-hop-trace {
  0%, 6%   { transform: scaleX(0); }
  28%, 88% { transform: scaleX(1); }
  96%, 100% { transform: scaleX(0); }
}

.viz-hop__node {
  position: relative;
  display: grid;
  place-items: center;
  width: 2.2rem;
  height: 2.2rem;
  border-radius: var(--r-sm);
  border: 1px solid var(--viz-line);
  background: var(--surface-2);
  color: var(--fg-2);
  opacity: 0.4;
  will-change: opacity;
  animation: vb-hop-node var(--viz-hop-dur) var(--viz-b-ease) infinite;
  animation-delay: calc(var(--i, 0) * var(--viz-hop-step));
}

@keyframes vb-hop-node {
  0%, 8%   { opacity: 0.4; }
  26%      { opacity: 1; }
  62%, 88% { opacity: 0.42; }
  96%, 100% { opacity: 0.4; }
}

.viz-hop__hop b {
  font-family: var(--viz-b-mono);
  font-size: var(--fs-micro);
  font-weight: 500;
  color: var(--fg-3);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}

/* The one hop that is actually broken. It does not dim with the rest. */
.viz-hop__hop--bad .viz-hop__node {
  color: var(--viz-b-warn);
  border-color: var(--viz-b-warn);
  animation-name: vb-hop-node-bad;
}

@keyframes vb-hop-node-bad {
  0%, 8%   { opacity: 0.4; }
  26%, 88% { opacity: 1; }
  96%, 100% { opacity: 0.4; }
}

.viz-hop__hop--bad b { color: var(--viz-b-warn); }

.viz-hop__node > i {
  position: absolute;
  inset: -0.3rem;
  border-radius: var(--r-md);
  border: 1px solid var(--viz-b-warn);
  opacity: 0;
  animation: vb-hop-ring var(--viz-hop-dur) ease-out infinite;
  animation-delay: calc(var(--i, 0) * var(--viz-hop-step));
}

@keyframes vb-hop-ring {
  0%, 48%  { transform: scale(0.85); opacity: 0; }
  62%      { transform: scale(1); opacity: 1; }
  76%      { transform: scale(1.08); opacity: 0.45; }
  88%      { transform: scale(1); opacity: 1; }
  96%, 100% { transform: scale(0.85); opacity: 0; }
}

.viz-hop__flag {
  margin: 0;
  justify-self: center;
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.25rem 0.6rem;
  border-radius: var(--r-full);
  border: 1px solid var(--viz-b-warn);
  color: var(--viz-b-warn);
  font-size: var(--fs-micro);
  font-weight: 650;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  white-space: nowrap;
  opacity: 0;
  will-change: transform, opacity;
  animation: vb-hop-flag var(--viz-hop-dur) var(--viz-b-ease) infinite;
}

@keyframes vb-hop-flag {
  0%, 52%  { opacity: 0; transform: translate3d(0, 0.3rem, 0); }
  68%, 86% { opacity: 1; transform: translate3d(0, 0, 0); }
  94%, 100% { opacity: 0; transform: translate3d(0, 0.3rem, 0); }
}

@media (max-width: 640px) {
  .viz-hop__node { width: 1.9rem; height: 1.9rem; }
  .viz-hop__node .ico { --ico-size: 0.875rem; }
  .viz-hop__seg { top: 0.95rem; }
  .viz-hop__hop b { font-size: 0.625rem; }
}

@media (max-width: 420px) {
  .viz-hop__node { width: 1.6rem; height: 1.6rem; }
  .viz-hop__seg { top: 0.8rem; }
  .viz-hop__hop b { font-size: 0.5625rem; letter-spacing: -0.02em; }
}


/* =====================================================================
   B8  TALLY - counter settle
   Teaching: volume is the problem, one number is the answer.
   Three reels roll. The two leading digits roll out and vanish, the
   last one decelerates onto a 1, and the unit label crosses from
   "events" to "incident" in the same beat. The easing does the whole
   job: it is very fast at 0% and almost stopped by 70%.
   ===================================================================== */

.viz-tally {
  --viz-tally-dur: 3.2s;
  --viz-tally-h: clamp(2.6rem, 8vw, 3.5rem);
  --viz-tally-w: calc(var(--viz-tally-h) * 0.64);
  --viz-tally-ease: cubic-bezier(0.1, 0.86, 0.18, 1);
}

.viz-tally__stage { display: grid; justify-items: center; gap: var(--sp-3); }

.viz-tally__stage .viz__label .ico { color: var(--viz-accent); }

.viz-tally__meter { display: flex; align-items: center; }

.viz-tally__wheel {
  display: block;
  width: var(--viz-tally-w);
  height: var(--viz-tally-h);
  overflow: hidden;
  will-change: transform, opacity;
}

.viz-tally__reel {
  display: block;
  will-change: transform;
  animation: vb-tally-spin var(--viz-tally-dur) var(--viz-tally-ease) infinite;
}

.viz-tally__reel b {
  display: block;
  height: var(--viz-tally-h);
  line-height: var(--viz-tally-h);
  font-size: calc(var(--viz-tally-h) * 0.86);
  font-weight: 300;
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--tr-1);
  text-align: center;
  color: var(--fg);
}

/* The two leading digits roll out of the frame with the noise. */
.viz-tally__wheel--lead {
  animation: vb-tally-drop var(--viz-tally-dur) var(--viz-b-ease) infinite;
}

/* The last digit settles, then slides left to sit centred on its own. */
.viz-tally__wheel--one {
  animation: vb-tally-shift var(--viz-tally-dur) var(--viz-b-ease) infinite;
}

.viz-tally__wheel--one .viz-tally__reel { animation-name: vb-tally-settle; }
.viz-tally__wheel--one .viz-tally__reel b { color: var(--viz-accent); }

@keyframes vb-tally-spin {
  0%        { transform: translate3d(0, 0, 0); }
  60%, 100% { transform: translate3d(0, calc(var(--viz-tally-h) * -15), 0); }
}

@keyframes vb-tally-settle {
  0%        { transform: translate3d(0, 0, 0); }
  70%, 100% { transform: translate3d(0, calc(var(--viz-tally-h) * -20), 0); }
}

@keyframes vb-tally-drop {
  0%, 56%   { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
  74%, 100% { opacity: 0; transform: translate3d(0, -0.5rem, 0) scale(0.7); }
}

@keyframes vb-tally-shift {
  0%, 60%   { transform: translate3d(0, 0, 0); }
  80%, 100% { transform: translate3d(calc(var(--viz-tally-w) * -1), 0, 0); }
}

.viz-tally__unit {
  position: relative;
  margin: 0;
  height: 1.6rem;
  width: 100%;
}

.viz-tally__unit > span {
  position: absolute;
  left: 50%;
  top: 0;
  transform: translateX(-50%);
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.2rem 0.6rem;
  border-radius: var(--r-full);
  font-size: var(--fs-micro);
  font-weight: 650;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  white-space: nowrap;
}

.viz-tally__unit-a { color: var(--fg-3); }

.viz-tally__unit-b {
  border: 1px solid var(--viz-accent);
  color: var(--viz-accent);
  opacity: 0;
}

.viz-tally__unit-b .ico { color: var(--viz-accent); }
.viz-tally__unit-a { animation: vb-tally-unit-a var(--viz-tally-dur) linear infinite; }
.viz-tally__unit-b { animation: vb-tally-unit-b var(--viz-tally-dur) var(--viz-b-ease) infinite; }

@keyframes vb-tally-unit-a { 0%, 56% { opacity: 1; } 72%, 100% { opacity: 0; } }

@keyframes vb-tally-unit-b {
  0%, 62%  { opacity: 0; transform: translateX(-50%) scale(0.92); }
  78%      { opacity: 1; transform: translateX(-50%) scale(1); }
  88%      { opacity: 1; transform: translateX(-50%) scale(1.05); }
  100%     { opacity: 1; transform: translateX(-50%) scale(1); }
}


/* =====================================================================
   B9  REDUCED MOTION END STATES

   site.css collapses every animation to 0.01ms with one iteration,
   which lands each loop on its 100% keyframe. For all eight of these
   that is the reset frame, which reads as broken. Each one gets the
   frame that carries the meaning instead.
   ===================================================================== */

@media (prefers-reduced-motion: reduce) {

  .viz-normalize [data-viz] *,
  .viz-normalize *,
  .viz-normalize *::before,
  .viz-normalize *::after,
  .viz-sieve *,
  .viz-sieve *::before,
  .viz-sieve *::after,
  .viz-branch *,
  .viz-branch *::before,
  .viz-branch *::after,
  .viz-sweep *,
  .viz-sweep *::before,
  .viz-sweep *::after,
  .viz-evidence *,
  .viz-evidence *::before,
  .viz-evidence *::after,
  .viz-zip *,
  .viz-zip *::before,
  .viz-zip *::after,
  .viz-hop *,
  .viz-hop *::before,
  .viz-hop *::after,
  .viz-tally *,
  .viz-tally *::before,
  .viz-tally *::after {
    animation: none !important;
  }

  /* B1 Normalize: every row on the grid, the schema rule drawn. */
  .viz-normalize__row { transform: none !important; opacity: 1 !important; }
  .viz-normalize__cols::after { transform: scaleX(1) !important; opacity: 1 !important; }

  /* B2 Sieve: six caught on the mesh, two resting in the tray. */
  .viz-sieve__ev {
    transform: translate3d(0, var(--viz-sv-catch), 0) scale(0.72) !important;
    opacity: 0.28 !important;
  }
  .viz-sieve__ev--pass {
    transform: translate3d(0, var(--viz-sv-drop), 0) scale(1) !important;
    opacity: 1 !important;
  }
  .viz-sieve__mesh::after { opacity: 0.6 !important; }
  .viz-sieve__tray::after { opacity: 0.6 !important; transform: scale(1) !important; }

  /* B3 Branch: the answered path lit, the rest dim, the flag showing. */
  .viz-branch__wire--lit { opacity: 1 !important; }
  .viz-branch__node--on { opacity: 1 !important; }
  .viz-branch__node--on > i { opacity: 1 !important; }
  .viz-branch__node--off { opacity: 0.18 !important; }
  .viz-branch__seed { opacity: 0.4 !important; transform: scale(1) !important; }
  .viz-branch__flag > span { opacity: 1 !important; transform: none !important; }

  /* B4 Sweep: beam parked, every device reporting. */
  .viz-sweep__beam { transform: rotate(52deg) !important; }
  .viz-sweep__dev > i { opacity: 1 !important; transform: scale(1) !important; }

  /* B5 Evidence: the card assembled and sealed. */
  .viz-evidence__frags li { opacity: 1 !important; transform: none !important; }
  .viz-evidence__seal { opacity: 1 !important; transform: scale(1) !important; }
  .viz-evidence__card::after { opacity: 0.75 !important; }

  /* B6 Zip: one merged column on one rail. */
  .viz-zip__row { transform: none !important; opacity: 1 !important; }
  .viz-zip__rows::before { transform: scaleY(1) !important; opacity: 0.75 !important; }
  .viz-zip__head--a,
  .viz-zip__head--b { opacity: 0 !important; }
  .viz-zip__head--one { opacity: 1 !important; }

  /* B7 Hop: whole path traced, one hop flagged. */
  .viz-hop__seg::after { transform: scaleX(1) !important; }
  .viz-hop__node { opacity: 0.42 !important; }
  .viz-hop__hop--bad .viz-hop__node { opacity: 1 !important; }
  .viz-hop__node > i { opacity: 1 !important; transform: scale(1) !important; }
  .viz-hop__flag { opacity: 1 !important; transform: none !important; }

  /* B8 Tally: settled on one, labelled incident. */
  .viz-tally__wheel--lead { opacity: 0 !important; }
  .viz-tally__wheel--one { transform: translate3d(calc(var(--viz-tally-w) * -1), 0, 0) !important; }
  .viz-tally__wheel--one .viz-tally__reel {
    transform: translate3d(0, calc(var(--viz-tally-h) * -20), 0) !important;
  }
  .viz-tally__unit-a { opacity: 0 !important; }
  .viz-tally__unit-b { opacity: 1 !important; transform: translateX(-50%) scale(1) !important; }
}
