/* ============================================================
   sacer.css — SACER product brief.
   Editorial split: sticky dark "stage" (animated diagrams) +
   paper accordion that drives which diagram is shown.
   ============================================================ */

.sb-split{ display:grid; grid-template-columns:43% 57%; align-items:start; }

/* ---------- LEFT: diagram stage (sticky, dark) ---------- */
.sb-stage{
  position:sticky; top:0; height:100vh; overflow:hidden;
  background:radial-gradient(130% 90% at 30% 25%, #23272c 0%, var(--panel) 52%, #0d0f0e 100%);
  color:var(--text-on-dark);
  border-right:1px solid var(--rule-on-dark);
}
.sb-stage__grid{
  position:absolute; inset:0; opacity:.5;
  background-image:
    linear-gradient(var(--rule-on-dark) 1px, transparent 1px),
    linear-gradient(90deg, var(--rule-on-dark) 1px, transparent 1px);
  background-size:54px 54px, 54px 54px;
  -webkit-mask-image:radial-gradient(80% 70% at 50% 45%, #000 55%, transparent);
          mask-image:radial-gradient(80% 70% at 50% 45%, #000 55%, transparent);
}
.sb-stage__head{ position:absolute; top:26px; left:30px; right:30px; display:flex; justify-content:space-between; z-index:4; }
.sb-stage__head .eyebrow{ color:var(--sage); }
.sb-stage__head .step{ font-family:var(--mono); font-size:12px; color:var(--peri-deep); letter-spacing:.08em; }

.sb-theater{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; padding:64px 40px; }
/* Diagrams switch INSTANTLY (no transition to freeze): the active one is
   always fully visible. Entrance motion below is pure enhancement. */
.diagram{ position:absolute; width:min(88%,540px); opacity:0; pointer-events:none; }
.diagram.is-active{ opacity:1; }

.sb-stage__caption{ position:absolute; left:30px; right:30px; bottom:26px; z-index:4; border-top:1px solid var(--rule-on-dark); padding-top:16px; }
.sb-stage__caption .ct{ font-size:19px; font-weight:300; color:#eef1ff; letter-spacing:.01em; }
.sb-stage__caption .cd{ font-family:var(--mono); font-size:12px; color:var(--text-on-dark-soft); margin-top:6px; letter-spacing:.02em; }

/* ---------- RIGHT: accordion panel (paper) ---------- */
.sb-panel{ padding:var(--sp-6) clamp(28px,4.5vw,64px) var(--sp-7); }
.sb-panel__intro{ max-width:46ch; margin-bottom:var(--sp-5); }
.sb-panel__intro h1{ font-size:var(--fs-h1); margin-top:20px; }
.sb-panel__intro .lead{ margin-top:22px; }
.sb-panel__quote{ font-family:var(--mono); font-size:14px; line-height:1.65; color:var(--slate); border-left:2px solid var(--peri-deep); padding-left:18px; margin-top:26px; }

/* accordion */
.accordion{ border-top:1px solid var(--rule); }
.acc-item{ border-bottom:1px solid var(--rule); }
.acc-head{
  width:100%; background:none; border:0; cursor:pointer; text-align:left; padding:0;
  font-family:var(--sans); color:var(--ink);
}
/* row content shifts on hover via transform, not padding — the button's own
   box (and the accordion border it sits above) stays put. */
.acc-head__row{
  display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:20px;
  padding:26px 4px; transition:transform var(--dur-base) var(--ease-standard);
}
.acc-head:hover .acc-head__row{ transform:translateX(var(--move-sm)); }
.acc-head .n{ font-family:var(--mono); font-size:12px; color:var(--sage); letter-spacing:.1em; }
.acc-head .h{ font-size:var(--fs-h3); font-weight:300; letter-spacing:-.01em; transition:color var(--dur-base) var(--ease-standard); }
.acc-item[data-open="true"] .acc-head .h{ font-weight:500; }
.acc-item[data-open="true"] .acc-head .n{ color:var(--peri-ink); }

/* +/- glyph */
.acc-icon{ position:relative; width:18px; height:18px; flex:none; }
.acc-icon::before,.acc-icon::after{ content:""; position:absolute; background:var(--slate); transition:transform var(--dur-base) var(--ease-standard), background var(--dur-base) var(--ease-standard); }
.acc-icon::before{ top:50%; left:0; right:0; height:1.5px; transform:translateY(-50%); }
.acc-icon::after{ left:50%; top:0; bottom:0; width:1.5px; transform:translateX(-50%); }
.acc-item[data-open="true"] .acc-icon::after{ transform:translateX(-50%) scaleY(0); }
.acc-item[data-open="true"] .acc-icon::before{ background:var(--peri-ink); }

/* Open/close is instant at the layout level — a class toggle, not a height
   transition — so items below shift once, in the same frame as the click
   (no animated height/layout property, per CLAUDE.md). The motion lives
   inside the panel instead: see .acc-body__inner's entrance below. */
.acc-body{ overflow:hidden; height:0; }
.acc-item[data-open="true"] .acc-body{ height:auto; }
.acc-body__inner{ padding:2px 4px 32px; max-width:54ch; }
@media (prefers-reduced-motion:no-preference){
  .acc-item[data-open="true"] .acc-body__inner{
    animation:accReveal var(--dur-slow) var(--ease-standard) both;
  }
}
@keyframes accReveal{
  from{ opacity:0; transform:translateY(var(--move-sm)); }
  to{ opacity:1; transform:translateY(0); }
}
.acc-body p{ color:var(--text-soft); }
.acc-body .points{ margin-top:20px; display:grid; gap:14px; }
.acc-body .points li{ display:grid; grid-template-columns:auto 1fr; gap:13px; align-items:start; font-size:15.5px; line-height:1.5; }
.acc-body .points .mk{ width:13px; height:13px; margin-top:5px; flex:none; }
.acc-body .points .mk path{ fill:var(--peri-ink); }
.acc-body .points b{ font-weight:600; }
.acc-body .more{ display:inline-flex; gap:9px; margin-top:26px; font-family:var(--mono); font-size:var(--fs-label); letter-spacing:.1em; text-transform:uppercase; color:var(--ink); border-bottom:1.5px solid var(--ink); padding-bottom:5px; }
.acc-body .more .arr{ transition:transform var(--dur-base) var(--ease-standard); }
.acc-body .more:hover .arr{ transform:translateX(calc(var(--move-sm) * .5)); }

/* mobile inline diagram (hidden on desktop) */
.acc-body__diagram{ display:none; }

/* ============================================================
   Diagram primitives (SVG) — shared classes + keyframes
   ============================================================ */
.dg-node{ fill:var(--panel-2); stroke:var(--sage); stroke-width:1.4; }
.dg-node-label{ font-family:var(--mono); font-size:11px; fill:var(--mist); letter-spacing:.04em; text-anchor:middle; }
.dg-line{ fill:none; stroke:var(--sage); stroke-width:1.4; }
.dg-line-soft{ fill:none; stroke:var(--rule-on-dark); stroke-width:1.2; }
.dg-judg{ fill:rgba(154,166,238,.12); stroke:var(--peri-deep); stroke-width:1.6; }
/* paint-order halo: judgment nodes have translucent fills, so route lines pass
   visibly behind their labels ("reason", "SACER") — the dark under-stroke is
   drawn behind the glyph fill and occludes the line where it crosses the text. */
.dg-judg-label{ font-family:var(--mono); font-size:11px; fill:var(--peri); letter-spacing:.04em; text-anchor:middle; paint-order:stroke; stroke:var(--panel); stroke-width:3px; }
/* Small mono sub-annotations (pipeline's "judgment only" callout, delivery's
   "engine" sub-label under "SACER") share one 9px modifier. */
.dg-annot{ font-size:9px; }
.dg-judg-tick{ stroke:var(--peri-deep); stroke-width:1.2; }
.dg-glow{ filter:drop-shadow(0 0 7px rgba(154,166,238,.85)); }
.dg-title{ font-family:var(--mono); font-size:11px; fill:var(--sage); letter-spacing:.14em; text-transform:uppercase; }
.dg-flow{ fill:var(--peri); }
/* Shared telemetry glyphs: confirmation ticks (pipeline, capabilities) and
   the small mono captions (pipeline, delivery). */
.dg-tick{ font-family:var(--mono); font-size:10px; fill:var(--sage); text-anchor:middle; }
.dg-caption{ font-family:var(--mono); font-size:9px; fill:var(--sage); text-anchor:middle; letter-spacing:.03em; }

/* Problem diagram: five ghosted candidate paths fanning out from the LLM.
   fan-line/fan-end are structural — visible by default (opacity .4 / .8) so
   a frozen frame (no-JS / reduced motion) still reads as "many possible
   paths, taken one at a time." fan-comet is a traveling pulse with no
   meaningful static frame (see the shared opacity:0 rule below). */
.fan-line{ fill:none; stroke:var(--sage); stroke-width:1.4; opacity:.4; }
.fan-end{ opacity:.8; }
.fan-dot{ fill:var(--peri); }
.fan-q{ font-family:var(--mono); font-size:10px; fill:var(--peri); text-anchor:middle; }

/* Traveling pulses (flow-dot and every comet added since) have no
   meaningful static frame — they're particles, not structure — so
   opacity:0 stays unconditional, not gated behind the no-preference query:
   gating it would leave a stray dot/comet frozen mid-line for
   prefers-reduced-motion users, which is worse than showing none. hex-verify
   joins this list for the same reason: it's a re-trace with no settled
   frame of its own (the spoke it overlays already carries the static
   frame) — reduced-motion users see the plain hex-spoke lines instead. */
.flow-dot, .fan-comet, .stem-comet, .input-comet, .adopt-comet, .license-comet, .hex-verify{ opacity:0; }
.fan-comet, .stem-comet, .input-comet, .adopt-comet, .license-comet{ fill:none; stroke:var(--peri); stroke-width:2; }
.hex-verify{ fill:none; stroke:var(--peri-deep); stroke-width:1.4; }

/* Diagram entrance choreography — plays whenever a diagram becomes the
   active one (accordion open / showDiagram activation), not just on page
   load: it's keyed off .diagram.is-active, which JS toggles per section.
   Base state outside the media query is the finished diagram (fully
   visible, lines fully drawn) so reduced-motion and no-JS never see
   hidden content — the FROM state below only exists inside the gate. */
:root{
  --dg-draw-dur: calc(var(--dur-slow) * 1.5); /* slightly longer than a standard reveal — the line has distance to cover */
  /* Pipeline-only entrance cadence: the route draws first, then nodes land
     in execution order. --dg-run-start holds nodes back until the spine has
     finished; --dg-run-cadence is the tick between them. --dg-ambient-start
     is the point where entrance has fully settled, so the ambient flow-dot
     and reason pulse can start clean instead of firing mid-entrance.
     Capabilities and problem reuse --dur-quick/--dur-base directly for
     their own cadences rather than forking new identical vars. */
  --dg-run-cadence: var(--dur-quick);
  --dg-run-start: var(--dg-draw-dur);
  /* = end of the last entrance element (caption, tick 7 + --dur-base):
     630 + 120×7 + 240 = 1710ms */
  --dg-ambient-start: calc(var(--dg-run-start) + var(--dg-run-cadence) * 7 + var(--dur-base));
}
@media (prefers-reduced-motion:no-preference){
  .diagram.is-active .pop{ animation:dgPop var(--dur-slow) var(--ease-emphasized) both; }
  .diagram.is-active .pop-2{ animation-delay:calc(var(--reveal-stagger) * 1); }
  .diagram.is-active .pop-3{ animation-delay:calc(var(--reveal-stagger) * 2); }
  .diagram.is-active .pop-4{ animation-delay:calc(var(--reveal-stagger) * 3); }
  .diagram.is-active .pop-5{ animation-delay:calc(var(--reveal-stagger) * 4); }
  .diagram.is-active .pop-6{ animation-delay:calc(var(--reveal-stagger) * 5); }

  /* stroke-dashoffset is paint-only (no layout) — a deliberate, contained
     exception to transform/opacity for the line-draw effect, mirroring the
     documented `linear` dgFlow exception below. */
  .draw, .draw-2{ stroke-dasharray:var(--len); stroke-dashoffset:var(--len); }
  .diagram.is-active .draw{ animation:dgDraw var(--dg-draw-dur) var(--ease-standard) both; }
  .diagram.is-active .draw-2{ animation:dgDraw var(--dg-draw-dur) var(--ease-standard) var(--reveal-stagger) both; }

  /* Pipeline only: override the generic pop stagger above — nodes wait for
     the full spine to draw, then land in execution order (parse → extract →
     reason → filter → format). Deterministic steps get a quick, exact
     dgPop (--dur-base/--ease-standard); reason keeps the slower emphasized
     default so it lands with weight. reason also skips a tick (+3 cadences,
     not +2) — the held beat before judgment — and its slower pop spans
     ticks 3–6½, so filter resumes at tick 5 and (like the stepper) nothing
     finishes before reason has settled: execution order is preserved both
     in starts and in landings. pop-6 (the caption) lands one tick after
     format.
     NB: these selectors tie the generic .diagram.is-active rules above on
     specificity and win by source order — this block must stay after them. */
  [data-diagram="pipeline"].is-active .pop-1,
  [data-diagram="pipeline"].is-active .pop-2,
  [data-diagram="pipeline"].is-active .pop-4,
  [data-diagram="pipeline"].is-active .pop-5,
  [data-diagram="pipeline"].is-active .pop-6{
    animation-duration:var(--dur-base);
    animation-timing-function:var(--ease-standard);
  }
  [data-diagram="pipeline"].is-active .pop-1{ animation-delay:var(--dg-run-start); }
  [data-diagram="pipeline"].is-active .pop-2{ animation-delay:calc(var(--dg-run-start) + var(--dg-run-cadence)); }
  [data-diagram="pipeline"].is-active .pop-3{ animation-delay:calc(var(--dg-run-start) + var(--dg-run-cadence) * 3); }
  [data-diagram="pipeline"].is-active .pop-4{ animation-delay:calc(var(--dg-run-start) + var(--dg-run-cadence) * 5); }
  [data-diagram="pipeline"].is-active .pop-5{ animation-delay:calc(var(--dg-run-start) + var(--dg-run-cadence) * 6); }
  [data-diagram="pipeline"].is-active .pop-6{ animation-delay:calc(var(--dg-run-start) + var(--dg-run-cadence) * 7); }

  /* Problem only: fan paths draw in at their settled ghost opacity (.4,
     declared unconditionally on .fan-line above) — dgDraw only moves
     stroke-dashoffset, so it never touches opacity. Each branch gets its
     own entrance stagger (off --reveal-stagger) independent of its ambient
     "hot" slot (assigned further below): path1→slot0, path2→slot2,
     path3→slot4, path4→slot1, path5→slot3 — a scrambled firing order so
     the run never reads as animating branches 1‑2‑3‑4‑5 in sequence. */
  .diagram.is-active .fan-line{
    stroke-dasharray:var(--len); stroke-dashoffset:var(--len);
    animation: dgDraw var(--dg-draw-dur) var(--ease-standard) both,
               dgFanIgnite var(--dur-ambient) linear infinite;
  }
  .diagram.is-active .fan-comet{
    stroke-dasharray:12 var(--len);
    animation: dgFanComet var(--dur-ambient) linear infinite;
  }
  .diagram.is-active .fan-end{
    animation: dgFanEndFlash var(--dur-ambient) linear infinite;
  }
  [data-diagram="problem"].is-active .fan-1 .fan-line{ animation-delay: calc(var(--reveal-stagger) * 1), 0s; }
  [data-diagram="problem"].is-active .fan-2 .fan-line{ animation-delay: calc(var(--reveal-stagger) * 2), calc(var(--dur-ambient) / 5 * 2); }
  [data-diagram="problem"].is-active .fan-3 .fan-line{ animation-delay: calc(var(--reveal-stagger) * 3), calc(var(--dur-ambient) / 5 * 4); }
  [data-diagram="problem"].is-active .fan-4 .fan-line{ animation-delay: calc(var(--reveal-stagger) * 4), calc(var(--dur-ambient) / 5 * 1); }
  [data-diagram="problem"].is-active .fan-5 .fan-line{ animation-delay: calc(var(--reveal-stagger) * 5), calc(var(--dur-ambient) / 5 * 3); }
  [data-diagram="problem"].is-active .fan-1 .fan-comet{ animation-delay: 0s; }
  [data-diagram="problem"].is-active .fan-2 .fan-comet{ animation-delay: calc(var(--dur-ambient) / 5 * 2); }
  [data-diagram="problem"].is-active .fan-3 .fan-comet{ animation-delay: calc(var(--dur-ambient) / 5 * 4); }
  [data-diagram="problem"].is-active .fan-4 .fan-comet{ animation-delay: calc(var(--dur-ambient) / 5 * 1); }
  [data-diagram="problem"].is-active .fan-5 .fan-comet{ animation-delay: calc(var(--dur-ambient) / 5 * 3); }
  [data-diagram="problem"].is-active .fan-1 .fan-end{ animation-delay: 0s; }
  [data-diagram="problem"].is-active .fan-2 .fan-end{ animation-delay: calc(var(--dur-ambient) / 5 * 2); }
  [data-diagram="problem"].is-active .fan-3 .fan-end{ animation-delay: calc(var(--dur-ambient) / 5 * 4); }
  [data-diagram="problem"].is-active .fan-4 .fan-end{ animation-delay: calc(var(--dur-ambient) / 5 * 1); }
  [data-diagram="problem"].is-active .fan-5 .fan-end{ animation-delay: calc(var(--dur-ambient) / 5 * 3); }
  /* The stem (prompt→LLM) carries its own short comet, once every run.
     (The matching LLM-ring pulse override lives in the ambient block below —
     it must sit after the generic .pulse rule to win the source-order tie.) */
  .diagram.is-active .stem-comet{
    stroke-dasharray:10 var(--len);
    animation: dgStemComet calc(var(--dur-ambient) / 5) linear infinite;
  }

  /* Capabilities only: hexagonal assembly. Hub pops first (bare .pop,
     default delay/duration). Then, clockwise from the top node, each spoke
     draws and its node pops the instant the spoke completes, one
     --dur-quick tick apart — the plan assembling itself. The ambient
     re-attestation overlay (hex-verify) is defined further below, alongside
     hex-sweep, since both share the same six clockwise delay slots. */
  .diagram.is-active .hex-spoke-1, .diagram.is-active .hex-spoke-2, .diagram.is-active .hex-spoke-3,
  .diagram.is-active .hex-spoke-4, .diagram.is-active .hex-spoke-5, .diagram.is-active .hex-spoke-6{
    stroke-dasharray:var(--len); stroke-dashoffset:var(--len);
    animation: dgDraw var(--dur-base) var(--ease-standard) both;
  }
  [data-diagram="capabilities"].is-active .hex-spoke-1{ animation-delay: var(--dur-slow); }
  [data-diagram="capabilities"].is-active .hex-spoke-2{ animation-delay: calc(var(--dur-slow) + var(--dur-quick)); }
  [data-diagram="capabilities"].is-active .hex-spoke-3{ animation-delay: calc(var(--dur-slow) + var(--dur-quick) * 2); }
  [data-diagram="capabilities"].is-active .hex-spoke-4{ animation-delay: calc(var(--dur-slow) + var(--dur-quick) * 3); }
  [data-diagram="capabilities"].is-active .hex-spoke-5{ animation-delay: calc(var(--dur-slow) + var(--dur-quick) * 4); }
  [data-diagram="capabilities"].is-active .hex-spoke-6{ animation-delay: calc(var(--dur-slow) + var(--dur-quick) * 5); }

  [data-diagram="capabilities"].is-active .hex-node-1{ animation-duration:var(--dur-base); animation-timing-function:var(--ease-standard); animation-delay: calc(var(--dur-slow) + var(--dur-base)); }
  [data-diagram="capabilities"].is-active .hex-node-2{ animation-duration:var(--dur-base); animation-timing-function:var(--ease-standard); animation-delay: calc(var(--dur-slow) + var(--dur-quick) + var(--dur-base)); }
  [data-diagram="capabilities"].is-active .hex-node-3{ animation-duration:var(--dur-base); animation-timing-function:var(--ease-standard); animation-delay: calc(var(--dur-slow) + var(--dur-quick) * 2 + var(--dur-base)); }
  [data-diagram="capabilities"].is-active .hex-node-4{ animation-duration:var(--dur-base); animation-timing-function:var(--ease-standard); animation-delay: calc(var(--dur-slow) + var(--dur-quick) * 3 + var(--dur-base)); }
  [data-diagram="capabilities"].is-active .hex-node-5{ animation-duration:var(--dur-base); animation-timing-function:var(--ease-standard); animation-delay: calc(var(--dur-slow) + var(--dur-quick) * 4 + var(--dur-base)); }
  [data-diagram="capabilities"].is-active .hex-node-6{ animation-duration:var(--dur-base); animation-timing-function:var(--ease-standard); animation-delay: calc(var(--dur-slow) + var(--dur-quick) * 5 + var(--dur-base)); }

  /* The verification sweep: box + label share the same ambient rise, one
     shared keyframe with six delays spaced var(--dur-ambient)/6 apart —
     a slow clockwise attestation cycle. */
  .diagram.is-active .hex-sweep-1, .diagram.is-active .hex-sweep-2, .diagram.is-active .hex-sweep-3,
  .diagram.is-active .hex-sweep-4, .diagram.is-active .hex-sweep-5, .diagram.is-active .hex-sweep-6{
    animation: dgHexSweep var(--dur-ambient) var(--ease-standard) infinite;
  }
  [data-diagram="capabilities"].is-active .hex-sweep-1{ animation-delay: 0s; }
  [data-diagram="capabilities"].is-active .hex-sweep-2{ animation-delay: calc(var(--dur-ambient) / 6); }
  [data-diagram="capabilities"].is-active .hex-sweep-3{ animation-delay: calc(var(--dur-ambient) / 6 * 2); }
  [data-diagram="capabilities"].is-active .hex-sweep-4{ animation-delay: calc(var(--dur-ambient) / 6 * 3); }
  [data-diagram="capabilities"].is-active .hex-sweep-5{ animation-delay: calc(var(--dur-ambient) / 6 * 4); }
  [data-diagram="capabilities"].is-active .hex-sweep-6{ animation-delay: calc(var(--dur-ambient) / 6 * 5); }

  /* Re-attestation overlay: each hex-verify re-traces its spoke, drawn OVER
     the soft spoke, during the same clockwise slot its node brightens
     (hex-sweep shares these exact six delays) — hub + node together read as
     "this guarantee, re-verified." A wipe (dashoffset var(--len)→0) is the
     correct pattern here, unlike the comets: this genuinely is a fresh trace
     of the whole spoke, not a short particle traversing a long path. Within
     its own 8s cycle: draws in 0→6%, holds to 10%, fades out by 14%, hidden
     the remaining 86% — motion-only, so it's hidden unconditionally outside
     the media gate (see the opacity:0 rule above), same as the comets. */
  .diagram.is-active .hex-verify{
    stroke-dasharray:var(--len); stroke-dashoffset:var(--len);
    animation: dgHexVerify var(--dur-ambient) var(--ease-standard) infinite;
  }
  [data-diagram="capabilities"].is-active .hex-verify-1{ animation-delay: 0s; }
  [data-diagram="capabilities"].is-active .hex-verify-2{ animation-delay: calc(var(--dur-ambient) / 6); }
  [data-diagram="capabilities"].is-active .hex-verify-3{ animation-delay: calc(var(--dur-ambient) / 6 * 2); }
  [data-diagram="capabilities"].is-active .hex-verify-4{ animation-delay: calc(var(--dur-ambient) / 6 * 3); }
  [data-diagram="capabilities"].is-active .hex-verify-5{ animation-delay: calc(var(--dur-ambient) / 6 * 4); }
  [data-diagram="capabilities"].is-active .hex-verify-6{ animation-delay: calc(var(--dur-ambient) / 6 * 5); }
}
@keyframes dgPop{
  from{ opacity:0; transform:translateY(var(--move-sm)); }
  to{ opacity:1; transform:translateY(0); }
}
@keyframes dgDraw{
  from{ stroke-dashoffset:var(--len); }
  to{ stroke-dashoffset:0; }
}
/* Ambient diagram loop rhythm — durations derived from --dur-ambient (8s) so
   every loop shares one base cadence, expressed as calc() per CLAUDE.md.
   dgFlow/dgStemComet/dgInputComet/dgOutComet/dgFanComet keep `linear` timing
   deliberately: they're constant-speed particle travel, not eased motion. */
:root{
  /* The pipeline loop is one run (travel) plus a settled pause before it
     repeats — so it doesn't reset the instant the dot lands on `format`.
     Travel speed is unchanged; the pause is added time, not slower motion.
     The phase-locked keyframe %s below (dgFlow, dgPulse, dgTick*) are baked to
     the .8 active fraction — the final 20% of every loop is the pause. */
  --dg-flow-active: calc(var(--dur-ambient) * .3);    /* 2.4s — travel time, unchanged */
  --dg-flow-pause:  calc(var(--dur-ambient) * .35);  /* .6s  — settled beat before repeat */
  --dg-flow-dur:    calc(var(--dg-flow-active) + var(--dg-flow-pause)); /* 3.0s period; active fraction .8 */
  --dg-pulse-dur:  var(--dg-flow-dur);               /* phase-locked to the flow-dot (pipeline-only consumer) — see below */
  --dg-stagger:    calc(var(--dur-ambient) * .075);  /* .6s — the flow-dot's initial start offset */
}
@media (prefers-reduced-motion:no-preference){
  .diagram.is-active .flow-dot{ animation:dgFlow var(--dg-flow-dur) linear var(--dg-stagger) infinite; }
  .diagram.is-active .pulse{ animation:dgPulse var(--dg-pulse-dur) var(--ease-standard) infinite; }

  /* Pipeline only: flow-dot, reason pulse and every confirmation tick start
     together, after entrance has settled (--dg-ambient-start), instead of
     firing mid-entrance. Same period + same start = permanently
     phase-locked: dgPulse's 50% peak lands inside the dot's dwell window
     (see dgFlow below) — the node brightens because the work arrived at it.
     NB: equal specificity with the generic rules above — wins by source
     order; keep this block after them. */
  [data-diagram="pipeline"].is-active .flow-dot{ animation-delay:var(--dg-ambient-start); }
  [data-diagram="pipeline"].is-active .pulse{ animation-delay:var(--dg-ambient-start); }
  [data-diagram="pipeline"].is-active .tick-parse{   animation:dgTickParse   var(--dg-flow-dur) linear var(--dg-ambient-start) infinite; }
  [data-diagram="pipeline"].is-active .tick-extract{ animation:dgTickExtract var(--dg-flow-dur) linear var(--dg-ambient-start) infinite; }
  [data-diagram="pipeline"].is-active .tick-filter{  animation:dgTickFilter  var(--dg-flow-dur) linear var(--dg-ambient-start) infinite; }
  [data-diagram="pipeline"].is-active .tick-format{  animation:dgTickFormat  var(--dg-flow-dur) linear var(--dg-ambient-start) infinite; }

  /* Problem only: the LLM ring pulses once per run (--dur-ambient/5 = 1.6s),
     phase-locked to the stem comet's identical period — dgPulse's 50% peak
     lands as the comet arrives at the node. NB: equal specificity with the
     generic .pulse rule above — wins by source order; keep after it. */
  [data-diagram="problem"].is-active .pulse{ animation-duration:calc(var(--dur-ambient) / 5); animation-delay:0s; }

  /* Delivery only: input comet + engine glow share one 2s beat
     (--dur-ambient/4); each beat alternately triggers one output emission
     (adopt, then license, then adopt…), so each branch's own period is 4s
     (--dur-ambient/2) offset by half of it (2s) from the other branch. */
  [data-diagram="delivery"].is-active .pulse{ animation-duration:calc(var(--dur-ambient) / 4); animation-delay:0s; }
  .diagram.is-active .input-comet{   stroke-dasharray:10 var(--len); animation:dgInputComet calc(var(--dur-ambient) / 4) linear infinite; }
  .diagram.is-active .adopt-comet{   stroke-dasharray:10 var(--len); animation:dgOutComet    calc(var(--dur-ambient) / 2) linear infinite; }
  .diagram.is-active .license-comet{ stroke-dasharray:10 var(--len); animation:dgOutComet    calc(var(--dur-ambient) / 2) linear calc(var(--dur-ambient) / 4) infinite; }
}
/* dgFlow: constant-speed travel (linear, deliberate exception — see entrance
   comment above) with a dwell inside the reason node, and absorption at
   both ends. Geometry (pipeline node centers, evenly spaced 74px apart):
     parse=0  extract=74  reason=148  filter=222  format=296 (=total travel)
   reason sits at the exact midpoint (148 = 296/2), so pre- and post-dwell
   travel are symmetric. Solving for a 12%-wide dwell centered so the two
   travel legs take equal time: travelStart=8%, dwellStart=D1, dwellEnd=D2,
   travelEnd=97% ⇒ (D1-8)=(97-D2)=X and D2-D1=12 ⇒ X=38.5, D1=46.5%, D2=58.5%.
   extract passes at 8+ (74/148)*38.5 = 27.25% (halfway through the first
   leg); filter passes at 58.5 + (74/148)*38.5 = 77.75% (halfway through the
   second). Absorption is keyed to the RING, not the dwell: the circle edge
   is at travel x=118/178, the "reason" label spans x≈127–169, and the dot's
   own radius is 4.5 — so the dot's leading edge first touches the label's
   box at travel x=122.5 (39.9%) and its trailing edge clears it at x=173.5
   (65.1%). The dot fades out 36→39.5% (absorbed crossing into the glass,
   fully gone before any glyph contact) and back in 65.5→68.5% (re-emitted
   past the label). The dot is never visible over the label — verified by
   bounding-rect intersection sampling, not eyeballing. dgPulse's fixed 50%
   peak lands centered in the hidden window (39.5–65.5%), so the ring
   brightens exactly while the work is "inside" the node. Arrival at format
   (97%) is followed by a fade-out to 100%, mirroring the absorption.
   NB: the derivation above is the 0–100 travel model. The keyframe stops
   below are these values × .8 (the active fraction): the dot lands on format
   at 77.6% and is fully absorbed by 80%, then everything holds settled from
   80–100% — the pause before the run repeats. Scaled node passes:
   extract=21.8, filter=62.2, format=77.6. */
@keyframes dgFlow{
  0%{ opacity:0; transform:translateX(0); }
  /* transform pinned here too — without it the dot's travel would
     interpolate from 0%, running ~1% ahead of the documented 8%-start
     model (× .8) that the tick %s and absorption window are derived from. */
  6.4%{ opacity:1; transform:translateX(0); }
  28.8%{ opacity:1; }
  31.6%{ opacity:0; }
  37.2%{ transform:translateX(148px); }
  46.8%{ transform:translateX(148px); }
  52.4%{ opacity:0; }
  54.8%{ opacity:1; }
  77.6%{ opacity:1; transform:translateX(296px); }
  80%{ opacity:0; transform:translateX(296px); }
  /* settled pause: dot parked at format and invisible until the run repeats. */
  100%{ opacity:0; transform:translateX(296px); }
}
@keyframes dgPulse{ 0%,100%{ opacity:.6; } 40%{ opacity:1; } }

/* Confirmation ticks: each one clears (opacity→0 by 4.8%, "the new run clears
   the board") then snaps back to full at the exact % the flow-dot passes
   its node (the 8 / 27.25 / 77.75 / 97 derivation above, × the .8 active
   fraction), and holds through the rest of the loop — including the pause. */
@keyframes dgTickParse{   0%{opacity:1;} 4.8%{opacity:0;} 6%{opacity:0;}     6.4%{opacity:1;}  100%{opacity:1;} }
@keyframes dgTickExtract{ 0%{opacity:1;} 4.8%{opacity:0;} 21.4%{opacity:0;}  21.8%{opacity:1;} 100%{opacity:1;} }
@keyframes dgTickFilter{  0%{opacity:1;} 4.8%{opacity:0;} 61.8%{opacity:0;}  62.2%{opacity:1;} 100%{opacity:1;} }
@keyframes dgTickFormat{  0%{opacity:1;} 4.8%{opacity:0;} 77.2%{opacity:0;}  77.6%{opacity:1;} 100%{opacity:1;} }

/* Problem diagram: one fan branch "ignites" per --dur-ambient/5 slot (see
   the entrance block above for the scrambled slot assignment). Ignite ramps
   .18→.9 by 2%, the comet overlay traverses 2→9%, the endpoint flashes to
   full 9→12%, everything decays back to ghost by 18% and holds for the
   remaining 82% of this branch's own 8s cycle — exactly one path is ever hot. */
@keyframes dgFanIgnite{ 0%{opacity:.18;} 2%{opacity:.9;} 9%{opacity:.9;} 18%{opacity:.18;} 100%{opacity:.18;} }
/* Comet traversal: with stroke-dasharray "d var(--len)", a dash of length d
   only fully clears the path (enters at the start, exits past the far end)
   if dashoffset runs from d (dash sitting just before position 0 — invisible)
   to -1*var(--len) (dash sitting just past the path's own length — invisible
   again). Animating len→0 instead (the dgDraw wipe pattern) only slides the
   dash ~d px near the start, which is the ~10%-of-path-then-stop bug this
   replaces. Every comet keyframe below follows the d→-len rule; only d (the
   dasharray's dash length, set alongside each comet's stroke-dasharray)
   changes per comet. */
/* NB: the negative to-value must be the LENGTH form calc(var(--len) * -1px),
   not number math like calc(-1 * var(--len)) — Chrome cannot interpolate
   stroke-dashoffset to a var()-dependent <number> calc (the dash freezes at
   its from-value; verified empirically), while multiplying the unitless
   --len by -1px yields a <length> that interpolates correctly. */
@keyframes dgFanComet{ 0%{opacity:0; stroke-dashoffset:12px;} 2%{opacity:1;} 9%{opacity:1; stroke-dashoffset:calc(var(--len) * -1px);} 10%{opacity:0;} 100%{opacity:0;} }
@keyframes dgFanEndFlash{ 0%{opacity:.25;} 9%{opacity:.25;} 12%{opacity:1;} 18%{opacity:.25;} 100%{opacity:.25;} }
/* The stem comet fires every slot (1.6s) — its arrival (50%) matches
   dgPulse's fixed peak (50%), so the LLM ring brightens as it lands. */
@keyframes dgStemComet{ 0%{opacity:0; stroke-dashoffset:10px;} 6%{opacity:1;} 50%{opacity:1; stroke-dashoffset:calc(var(--len) * -1px);} 58%{opacity:0;} 100%{opacity:0;} }

/* Capabilities: one shared clockwise "attestation" rise, six delays spaced
   var(--dur-ambient)/6 apart (see the entrance block above). */
@keyframes dgHexSweep{ 0%{opacity:.7;} 8.33%{opacity:1;} 16.67%{opacity:.7;} 100%{opacity:.7;} }
/* Capabilities: hex-verify's re-trace, sharing hex-sweep's six delay slots.
   A wipe, not a traversal (see the entrance-block comment above the
   hex-verify rules) — dashoffset var(--len)→0 draws the whole spoke in. */
@keyframes dgHexVerify{ 0%{opacity:0; stroke-dashoffset:var(--len);} 1%{opacity:1;} 6%{opacity:1; stroke-dashoffset:0;} 10%{opacity:1;} 14%{opacity:0;} 100%{opacity:0;} }

/* Delivery: input comet arrives at the engine at 50% (matching dgPulse's
   peak); each output comet emits at 25% of its own cycle (adopt at local
   t=1s, license's delayed cycle put its own local t=1s at global t=3s) —
   arrivals alternate with the engine's 2s pulse beat. */
@keyframes dgInputComet{ 0%{opacity:0; stroke-dashoffset:10px;} 10%{opacity:1;} 50%{opacity:1; stroke-dashoffset:calc(var(--len) * -1px);} 60%{opacity:0;} 100%{opacity:0;} }
@keyframes dgOutComet{ 0%{opacity:0; stroke-dashoffset:10px;} 25%{opacity:1;} 65%{opacity:1; stroke-dashoffset:calc(var(--len) * -1px);} 75%{opacity:0;} 100%{opacity:0;} }

/* ---------- Responsive ----------
   Desktop keeps the editorial split. At <=900px the side stage is
   dropped entirely and each diagram is moved inline into its own
   accordion section (see sacer-brief.js), animating in context. */
@media (max-width:900px){
  .sb-split{ grid-template-columns:1fr; }
  .sb-stage{ display:none; }
  .sb-panel{ padding-top:var(--sp-5); }

  .acc-body__diagram{
    display:flex; justify-content:center; align-items:center;
    background:radial-gradient(130% 110% at 50% 30%, #23272c 0%, var(--panel) 55%, #0d0f0e 100%);
    border:1px solid var(--rule-on-dark); border-radius:var(--radius);
    padding:30px 20px; margin-bottom:26px; overflow:hidden;
  }
  .acc-body__diagram .diagram{
    position:static; width:100%; max-width:320px; height:auto; transform:none;
  }
}
@media (max-width:620px){
  .sb-panel{ padding-left:var(--gutter); padding-right:var(--gutter); }
  .acc-head__row{ padding:22px 2px; gap:14px; }
  .acc-head .h{ font-size:20px; }
  .acc-body__inner{ padding-bottom:26px; }
  .acc-body__diagram{ padding:24px 14px; }
}