/* ============================================================================
   § 02 — THEME CARD ILLUSTRATIONS
   Extracted from index.html so the animations can be worked on in isolation.
   ============================================================================

   Six self-animating SVG "screens" for the § 02 theme explorer: a data-science
   scatter plot, a machine-learning rockfall detector, an LLM/RAG pipeline, a
   data-engineering delay cascade, an interactive glacier-retreat dataviz, and
   a medical-imaging segmentation pass. The markup for each is generated in
   theme-animations.js; the timing lives here.

   PURELY PRESENTATIONAL. The § 02 card list, the theme rotator and every other
   section of the landing page work without this file — it is a work-in-progress
   experiment, which is why it sits in its own file. When the animations are
   final, fold both this file and theme-animations.js back into index.html and
   drop the <link>/<script> tags there.

   Loaded as a plain stylesheet AFTER index.html's inline <style>, so these
   rules win on source order.

   TIMING CONTRACT: every animation must complete inside THEME_ANIM_MS
   (theme-animations.js). The theme rotator rebuilds the panel from scratch on
   each rotation, which restarts every animation from frame 0 — an animation
   still running when the panel is rebuilt simply restarts, so nothing is lost,
   but it will look like a stutter. Keep the longest delay + duration under
   THEME_ANIM_MS.

   Every duration and delay below is written as calc(<n>s * var(--anim-scale)),
   so --anim-scale on .theme-anim retimes the whole set at once. The ds
   renderer emits the same form in inline styles for its per-dot stagger, so
   it follows the variable too. Note this is tuned separately from
   THEME_ANIM_MS: the scale is how fast the animation plays, THEME_ANIM_MS is
   how long the card stays on screen afterwards. Both are safe to change
   independently.

   MARKUP CONTRACT: an element that both sits at a position and animates must be
   two nested <g>s — the OUTER carries transform="translate(...)", the INNER
   carries the animation class. A keyframe that animates `transform` sets the
   CSS transform property, which overrides the SVG transform *attribute* rather
   than composing with it, so putting both on one element silently discards the
   offset. The visible symptoms are an element landing on its anchor instead of
   beside it, and anything ending at translateY(0) sliding to the top of the
   panel. This bit all four cards and the ml-bbox; see the note in the `de`
   renderer in theme-animations.js.
   ========================================================================== */

/* ---- how much room the animation gets -------------------------------------
   The panel is a two-column grid (index.html): text left, animation right.
   The animation box keeps the 480×300 drawing ratio and fills its column,
   so it is ~700px wide on a laptop instead of the old fixed 280px height. */

/* ---- THEME ANIMATIONS (§ 02 card illustrations) ---- */
.theme-anim{position:relative;width:100%;height:auto;aspect-ratio:480/300;margin-top:0;border-radius:10px;
  /* Retimes every duration/delay below; see the TIMING CONTRACT note. */
  --anim-scale:1.5;
  background:var(--paper-2);border:1px solid var(--line);overflow:hidden;z-index:1}
.theme-anim svg{width:100%;height:100%;display:block}
.theme-anim text{user-select:none}
.theme-anim .ta-title{font-size:13px;font-weight:600;fill:var(--ink)}
.theme-anim .ta-mono{font-family:var(--mono);font-size:8.5px}

/* Data Science: weather × harvest-date cross-reference (Valais 2004–2023).
   Two measures that neither share a scale nor a band: gridlines + left labels
   are the harvest dates (purple, top band), the right labels are summer
   temperature (yellow, bottom band) and carry no gridlines. Mapping both
   series into the full plot made them interleave — see the `ds` comment in
   theme-animations.js. */
.theme-anim .ds-axis{stroke:var(--muted);stroke-width:1.2;opacity:.35}
.theme-anim .ds-tick{stroke:var(--muted);stroke-width:.5;opacity:.16}
.theme-anim .ds-axis-tick{font-family:var(--mono);font-size:8.5px;fill:var(--muted);opacity:.85}
.theme-anim .ds-axis-title{font-family:var(--mono);font-size:8.5px;font-weight:700}
.theme-anim .ds-link{stroke:var(--ink);stroke-width:1;opacity:0}
.theme-anim .ds-chip{opacity:0}
.theme-anim .ds-chip rect{stroke-width:1}
.theme-anim .ds-chip--harvest rect{fill:color-mix(in srgb, var(--purple,#8a6daa) 14%, var(--paper));stroke:var(--purple,#8a6daa)}
.theme-anim .ds-chip--temp rect{fill:color-mix(in srgb, var(--tc,var(--yellow)) 14%, var(--paper));stroke:var(--tc,var(--yellow))}
/* The conclusion wears the same box but a heavier tint, so it reads as the
   takeaway rather than a third measurement. */
.theme-anim .ds-chip--key rect{fill:color-mix(in srgb, var(--purple,#8a6daa) 24%, var(--paper));stroke:var(--purple,#8a6daa);stroke-width:1.3}
.theme-anim .ds-chip-text{font-family:var(--mono);font-size:8.5px;font-weight:700}
.theme-anim .ds-chip--harvest .ds-chip-text,
.theme-anim .ds-chip--key .ds-chip-text{fill:var(--purple,#8a6daa)}
.theme-anim .ds-chip--temp .ds-chip-text{fill:var(--tc,var(--yellow))}
.theme-anim .ds-method{font-family:var(--mono);font-size:8.5px;fill:var(--muted);opacity:0}
.theme-anim .ds-dot{opacity:0;transform:scale(0);
  transform-origin:center;transform-box:fill-box}
.theme-anim .ds-dot--temp{fill:var(--tc,var(--yellow))}
.theme-anim .ds-dot--harvest{fill:var(--purple,#8a6daa)}
.theme-anim .ds-trend{stroke:var(--tc,var(--yellow));stroke-width:1.2;fill:none;opacity:.5;
  stroke-dasharray:500;stroke-dashoffset:500}
.theme-anim .ds-trend--harvest{stroke:var(--purple,#8a6daa);stroke-width:2.2;opacity:.9}
/* Forecast layer (see the ds renderer): the shaded 2023–2030 zone, the
   dashed "now" line, the ±1σ cone and the dashed forecast curve, revealed
   by a clip rect that scales open from the "now" line. */
.theme-anim .ds-zone{fill:var(--purple,#8a6daa);fill-opacity:.05;opacity:0}
.theme-anim .ds-zone-label{font-family:var(--mono);font-size:7.5px;letter-spacing:.08em;text-transform:uppercase;fill:var(--muted);opacity:0}
.theme-anim .ds-now{stroke:var(--muted);stroke-width:1;stroke-dasharray:3 3;opacity:0}
.theme-anim .ds-now-label{opacity:0}
.theme-anim .ds-cone{fill:var(--purple,#8a6daa);fill-opacity:.14;stroke:none}
.theme-anim .ds-fore{fill:none;stroke:var(--purple,#8a6daa);stroke-width:2;stroke-dasharray:5 4;opacity:.9}
.theme-anim .ds-fore-whisker{stroke:var(--purple,#8a6daa);stroke-width:1.2;opacity:.7}
.theme-anim .ds-dot--fore{fill:var(--paper);stroke:var(--purple,#8a6daa);stroke-width:2.2}
.theme-anim .ds-reveal{transform-box:fill-box;transform-origin:left center;transform:scaleX(0)}
.theme-anim .ds-chip--fore rect{fill:color-mix(in srgb, var(--purple,#8a6daa) 24%, var(--paper));stroke:var(--purple,#8a6daa);stroke-width:1.3}
.theme-anim .ds-chip--fore .ds-chip-text{fill:var(--purple,#8a6daa)}
.theme-anim .ds-ping{fill:none;stroke:var(--tc,var(--yellow));stroke-width:1.4;opacity:0;
  transform-box:fill-box;transform-origin:center}
.theme-anim .ds-hot-leader{stroke:var(--tc,var(--yellow));stroke-width:1;stroke-dasharray:2 2;opacity:0}
.theme-anim .ds-hot{font-family:var(--mono);font-size:7.5px;font-weight:700;fill:var(--tc,var(--yellow));opacity:0}
.theme-anim .ds-title{font-size:13px;font-weight:600;fill:var(--ink)}

/* LIGHT THEME ONLY — the temperature series (--tc on this card is
   var(--yellow), #f7f19f) is a highlighter pastel: it is drawn to sit under
   ink, not to be read against it. On the card's own light background
   (--paper-2, #f4f5f7) it measures ~1.1:1, so the temperature points, the
   trend interpolated through them and the "°C" axis label were all but
   invisible — while everything else in the chart (axis, ticks, labels, the
   purple harvest series) uses ink weights and was fine. Swapping --tc for
   the whole subtree of this one animation fixes all four at once, because
   every yellow mark in the ds renderer reads --tc: the dots (.ds-dot--temp),
   the interpolation (.ds-trend, whose --harvest sibling is explicitly purple
   and stays so), the legend chip and the right-hand axis title.

   --yellow-ink is the palette's own answer to "yellow on paper" (#8a7d1c →
   3.8:1 on paper-2, and it is already what the page uses for yellow text on
   paper), so the chart now reads at the same level as the rest of the light
   theme. Dark is deliberately untouched: there --yellow is the readable one
   and --yellow-ink resolves to #ffe875, i.e. the pastel already passes on
   #16161f and moving it would be a regression.

   Two selectors because the page has two light states and two dark ones:
   data-theme only exists once the toggle has been used, and before that the
   palette follows prefers-color-scheme — so "light" is either an explicit
   data-theme="light", or no attribute while the OS asks for light. (The
   mirror pair for dark, at the top of index.html, is data-theme="dark" and
   prefers-color-scheme:dark with :not([data-theme="light"]).) */
:root[data-theme="light"] .theme-anim .ds-anim{--tc:var(--yellow-ink)}
@media(prefers-color-scheme:light){
  :root:not([data-theme="dark"]) .theme-anim .ds-anim{--tc:var(--yellow-ink)}
}

/* Machine Learning: camera & rockfall.

   Four beats, authored at 0.6 + 0.38 + 0.85 + 0.4s — 2.6s end to end, which is
   the joint-longest with the `de` card once --anim-scale is applied (3.9s of
   the 7s THEME_ANIM_MS budget; keep it that way, see the TIMING CONTRACT).

   1. 0.00s  rock detaches from the ridge and falls down the mountainside
   2. 0.60s  it stops dead a third of the way down; the box snaps over it
   3. 1.00s  the predicted two-thirds draws itself on, all the way to the road
   4. 1.85s  the impact mark lands, then the alert banner slides up

   The freeze is at 1/3 of the arc LENGTH, so the two halves are 41.8 and 83.7
   units — a 1:2 split that leaves the interesting part (the long predicted
   descent to the road) as the bulk of the animation. That also means the two
   halves need different durations or the rock visibly changes speed across the
   freeze: 0.6s and 0.85s give 70 u/s then 98 u/s, a 1.4x acceleration, which
   is what gravity should look like. Splitting the old 1:2 ratio evenly in time
   instead would have shown a 2.5x jump the instant the model took over.

   TIMING FUNCTION: the fall and the prediction are both `linear`, and that is
   load-bearing rather than lazy. Each keyframe sits at 0/25/50/75/100% of its
   arc LENGTH (see ml-rock-fall / ml-pred-run), so linear-in-time is
   linear-in-distance and the rock's keyframe vertices land exactly on the drawn
   curve — the line can never outrun the rock. The gravity read is not in the
   easing: the cubic itself steepens as it falls, so horizontal speed bleeds off
   into vertical speed on its own. An earlier revision eased the rock IN and
   drew its arc OUT; the arc finished at t=0.4s while the rock was still
   halfway down.

   The rock's last keyframe IS the freeze point. Do not give it a settle or an
   overshoot: the hard stop is the beat. Anything that eases it past the box
   reads as "the animation ran out" instead of "the detector has it". */
.theme-anim .ml-rec-dot{animation:rec-blink calc(1s * var(--anim-scale)) ease-in-out infinite}
.theme-anim .ml-rock{transform-box:fill-box;transform-origin:center;
  animation:ml-rock-fall calc(0.6s * var(--anim-scale)) linear forwards}
/* Reticle snaps onto the rock. It scales about its own fill-box centre, which
   is why the label tab is a sibling group rather than a child: a 126px tab
   inside here would push that centre ~9px off the rock. */
.theme-anim .ml-bbox{opacity:0;transform-box:fill-box;transform-origin:center;
  animation:ml-bbox-snap calc(0.38s * var(--anim-scale)) calc(0.62s * var(--anim-scale)) cubic-bezier(0.34,1.56,0.64,1) forwards}
/* Label tab fades in just behind the snap — box lands first, then names itself. */
.theme-anim .ml-tag{opacity:0;
  animation:anim-fade-in calc(0.28s * var(--anim-scale)) calc(0.7s * var(--anim-scale)) ease-out forwards}
/* Observed arc. Normalised to pathLength=100 in the markup, so 100 is the whole
   path and a dashoffset sweep from 100 reveals it end to end. */
.theme-anim .ml-traj-seen{stroke-dasharray:100;stroke-dashoffset:100;
  animation:trend-draw calc(0.6s * var(--anim-scale)) linear forwards}
/* Predicted arc: solid mask path does the revealing, dashes stay dashed. The
   mask stroke is 7px wide against a 1.7px visible stroke so the dashes are cut
   cleanly and the leading dot isn't clipped by its own cap. */
.theme-anim .ml-pred-mask{stroke-dasharray:100;stroke-dashoffset:100;
  animation:trend-draw calc(0.85s * var(--anim-scale)) calc(1s * var(--anim-scale)) linear forwards}
.theme-anim .ml-pred-head{opacity:0;transform-box:fill-box;transform-origin:center;
  animation:ml-pred-move calc(0.85s * var(--anim-scale)) calc(1s * var(--anim-scale)) linear forwards,
            ml-pred-run calc(0.85s * var(--anim-scale)) calc(1s * var(--anim-scale)) linear forwards}
/* Saliency halo and HUD come in with the box; the class bars grow from zero. */
.theme-anim .ml-saliency{opacity:0;
  animation:anim-fade-in calc(0.5s * var(--anim-scale)) calc(0.62s * var(--anim-scale)) ease-out forwards}
.theme-anim .ml-hud{opacity:0;
  animation:anim-fade-in calc(0.3s * var(--anim-scale)) calc(0.2s * var(--anim-scale)) ease-out forwards}
.theme-anim .ml-score{transform-box:fill-box;transform-origin:left center;transform:scaleX(0);
  animation:ds-reveal calc(0.5s * var(--anim-scale)) calc(0.75s * var(--anim-scale)) ease-out forwards}
.theme-anim .ml-traj-cap{opacity:0;
  animation:anim-fade-in calc(0.3s * var(--anim-scale)) calc(1.85s * var(--anim-scale)) ease-out forwards}
.theme-anim .ml-impact{opacity:0;transform-box:fill-box;transform-origin:center;
  animation:ml-impact-pop calc(0.35s * var(--anim-scale)) calc(1.85s * var(--anim-scale)) cubic-bezier(0.34,1.56,0.64,1) forwards}
.theme-anim .ml-impact-ping{transform-box:fill-box;transform-origin:center;
  animation:ml-ping calc(1.6s * var(--anim-scale)) calc(1.85s * var(--anim-scale)) ease-out infinite}
.theme-anim .ml-alert{opacity:0;transform:translateY(6px);
  animation:anim-slide-up calc(0.4s * var(--anim-scale)) calc(2.2s * var(--anim-scale)) ease-out forwards}

/* LLM: RAG & company document search */
.theme-anim .llm-query{opacity:0;transform:translateY(-6px);
  animation:llm-fade-down calc(0.4s * var(--anim-scale)) calc(0.2s * var(--anim-scale)) ease-out forwards}
.theme-anim .llm-scan-beam{opacity:0;
  animation:llm-scan-sweep calc(1.1s * var(--anim-scale)) calc(0.6s * var(--anim-scale)) ease-in-out forwards}
.theme-anim .llm-chunk-highlight{opacity:0;transform:scale(0.96);transform-box:fill-box;transform-origin:center;
  animation:llm-chunk-pop calc(0.35s * var(--anim-scale)) calc(1.0s * var(--anim-scale)) cubic-bezier(0.34,1.56,0.64,1) forwards}
.theme-anim .llm-flow-arrow{stroke-dasharray:120;stroke-dashoffset:120;
  animation:trend-draw calc(0.7s * var(--anim-scale)) calc(1.2s * var(--anim-scale)) ease-out forwards}
.theme-anim .llm-reply{opacity:0;transform:translateY(6px);
  animation:anim-slide-up calc(0.45s * var(--anim-scale)) calc(1.8s * var(--anim-scale)) ease-out forwards}
/* Conclusion box, last beat: 2.35 + 0.4 = 2.75s authored (4.1s at
   --anim-scale 1.5), the joint-longest with the `de` footer and inside
   THEME_ANIM_MS. It has to land AFTER the reply settles, otherwise the card
   states its conclusion while the answer it is concluding about is still
   sliding in. */
.theme-anim .llm-verdict{opacity:0;transform:translateY(6px);
  animation:anim-slide-up calc(0.4s * var(--anim-scale)) calc(2.35s * var(--anim-scale)) ease-out forwards}

/* Data Engineering: Swiss rail delays */
.theme-anim .de-incident-ring{transform-box:fill-box;transform-origin:center;
  animation:de-ping calc(1.6s * var(--anim-scale)) calc(0.4s * var(--anim-scale)) ease-out infinite}
.theme-anim .de-badge-1{opacity:0;transform:scale(0);transform-box:fill-box;transform-origin:center;
  animation:dot-appear calc(0.35s * var(--anim-scale)) calc(0.6s * var(--anim-scale)) cubic-bezier(0.34,1.56,0.64,1) forwards}
.theme-anim .de-flow-line-1{stroke-dasharray:140;stroke-dashoffset:140;
  animation:trend-draw calc(0.7s * var(--anim-scale)) calc(0.9s * var(--anim-scale)) ease-out forwards}
.theme-anim .de-badge-2{opacity:0;transform:scale(0);transform-box:fill-box;transform-origin:center;
  animation:dot-appear calc(0.35s * var(--anim-scale)) calc(1.5s * var(--anim-scale)) cubic-bezier(0.34,1.56,0.64,1) forwards}
.theme-anim .de-flow-line-2{stroke-dasharray:140;stroke-dashoffset:140;
  animation:trend-draw calc(0.7s * var(--anim-scale)) calc(1.6s * var(--anim-scale)) ease-out forwards}
.theme-anim .de-badge-3{opacity:0;transform:scale(0);transform-box:fill-box;transform-origin:center;
  animation:dot-appear calc(0.35s * var(--anim-scale)) calc(2.2s * var(--anim-scale)) cubic-bezier(0.34,1.56,0.64,1) forwards}
.theme-anim .de-footer{opacity:0;transform:translateY(6px);
  animation:anim-slide-up calc(0.4s * var(--anim-scale)) calc(2.3s * var(--anim-scale)) ease-out forwards}

/* Visualisation: Aletsch Glacier retreat.
   Terrain is lost one era at a time. Three handover windows sit on the
   scrubber's travel (18-34%, 45-61%, 72-88%), each one dissolving a solid
   body to reveal the next one underneath it; between windows the current
   extent sits alone and fully opaque. So the viewer always sees exactly one
   glacier, and it is never a dissolve of several outlines at once.
   Inside a window the outgoing and incoming opacities move together on
   purpose — the incoming era must be there before the outgoing one has
   finished clearing, or the map shows a hole through to the paper.
   Base opacity mirrors each 0% keyframe so the pre-delay frame shows the
   full 1850 glacier rather than a blank or half-built map. */
.theme-anim .viz-knob{animation:viz-slide-knob calc(2.2s * var(--anim-scale)) calc(0.4s * var(--anim-scale)) ease-in-out forwards}
.theme-anim .viz-bar{stroke-dasharray:var(--rail);stroke-dashoffset:var(--rail);
  animation:trend-draw calc(2.2s * var(--anim-scale)) calc(0.4s * var(--anim-scale)) ease-in-out forwards}
/* Lost-ice bands over the aerial image: all three are there in 1850 and melt
   away in turn as the knob passes 1920, 1980 and 2024. Same clock as the
   knob, so the snout recedes under the moving year. */
.theme-anim .viz-ice{fill:#dfe9f3;fill-opacity:.82;stroke:#ffffff;stroke-width:.8}
.theme-anim .viz-ice-1850{animation:viz-retreat-1850 calc(2.2s * var(--anim-scale)) calc(0.4s * var(--anim-scale)) ease-in-out forwards}
.theme-anim .viz-ice-1920{animation:viz-melt-1920 calc(2.2s * var(--anim-scale)) calc(0.4s * var(--anim-scale)) ease-in-out forwards}
.theme-anim .viz-ice-1980{animation:viz-melt-1980 calc(2.2s * var(--anim-scale)) calc(0.4s * var(--anim-scale)) ease-in-out forwards}
.theme-anim .viz-front text,.theme-anim .viz-place text,.theme-anim .viz-src{paint-order:stroke;stroke:rgba(20,21,40,.55);stroke-width:2px;stroke-linejoin:round}
.theme-anim .viz-front-2024{opacity:0;animation:viz-lbl-in-2024 calc(2.2s * var(--anim-scale)) calc(0.4s * var(--anim-scale)) ease-in-out forwards}
.theme-anim .viz-lbl-2024{opacity:0;animation:viz-lbl-in-2024 calc(2.2s * var(--anim-scale)) calc(0.4s * var(--anim-scale)) ease-in-out forwards}
.theme-anim .viz-lbl-2024 text{paint-order:stroke;stroke:rgba(20,21,40,.55);stroke-width:2px;stroke-linejoin:round}
.theme-anim .viz-stat-card{opacity:0;transform:translateY(6px);
  animation:anim-slide-up calc(0.4s * var(--anim-scale)) calc(2.2s * var(--anim-scale)) ease-out forwards}

/* Medical Informatics: lesion segmentation */
.theme-anim .med-scan{animation:med-sweep calc(1.2s * var(--anim-scale)) calc(0.2s * var(--anim-scale)) ease-in-out forwards}
.theme-anim .med-contour{stroke-dasharray:240;stroke-dashoffset:240;fill-opacity:0;
  animation:med-contour-draw calc(0.9s * var(--anim-scale)) calc(1.1s * var(--anim-scale)) ease-out forwards}
.theme-anim .med-points{opacity:0;transform:scale(0);transform-box:fill-box;transform-origin:center;
  animation:dot-appear calc(0.3s * var(--anim-scale)) calc(1.7s * var(--anim-scale)) cubic-bezier(0.34,1.56,0.64,1) forwards}
.theme-anim .med-calipers{opacity:0;animation:anim-fade-in calc(0.4s * var(--anim-scale)) calc(1.9s * var(--anim-scale)) ease-out forwards}
.theme-anim .med-badge{opacity:0;transform:translateX(6px);
  animation:med-slide-left calc(0.4s * var(--anim-scale)) calc(2.0s * var(--anim-scale)) ease-out forwards}
.theme-anim .med-footer{opacity:0;transform:translateY(6px);
  animation:anim-slide-up calc(0.4s * var(--anim-scale)) calc(2.2s * var(--anim-scale)) ease-out forwards}

/* Shared animation keyframes */
@keyframes dot-appear{
  0%{transform:scale(0);opacity:0}
  60%{transform:scale(1.3);opacity:.95}
  100%{transform:scale(1);opacity:.85}
}
/* Secondary ds series: same pop, but settles lighter so the purple harvest
   dots stay the subject. dot-appear hardcodes opacity .85, hence its own. */
@keyframes ds-dot-soft{
  0%{transform:scale(0);opacity:0}
  100%{transform:scale(1);opacity:.55}
}
@keyframes ds-link-fade{to{opacity:.16}}
@keyframes ds-reveal{to{transform:scaleX(1)}}
@keyframes trend-draw{
  to{stroke-dashoffset:0}
}
@keyframes rec-blink{0%,100%{opacity:1}50%{opacity:0.2}}
@keyframes anim-slide-up{to{opacity:1;transform:translateY(0)}}
@keyframes anim-fade-in{to{opacity:1}}
@keyframes ml-bbox-snap{
  0%{opacity:0;transform:scale(1.4)}
  100%{opacity:1;transform:scale(1)}
}
@keyframes ml-impact-pop{
  0%{opacity:0;transform:scale(1.7)}
  100%{opacity:1;transform:scale(1)}
}
@keyframes ml-ping{
  0%{transform:scale(0.6);opacity:0.9}
  100%{transform:scale(2.6);opacity:0}
}
@keyframes llm-fade-down{to{opacity:1;transform:translateY(0)}}
@keyframes llm-scan-sweep{
  0%{opacity:0;transform:translateY(0)}
  30%{opacity:0.85;transform:translateY(16px)}
  70%{opacity:0.85;transform:translateY(32px)}
  100%{opacity:0;transform:translateY(42px)}
}
@keyframes llm-chunk-pop{to{opacity:1;transform:scale(1)}}
@keyframes de-ping{
  0%{transform:scale(1);opacity:0.9}
  100%{transform:scale(2.2);opacity:0}
}
@keyframes viz-slide-knob{
  0%{transform:translateX(0)}
  100%{transform:translateX(var(--run))}
}
@keyframes viz-retreat-1850{0%,18%{opacity:1}34%,100%{opacity:0}}
@keyframes viz-melt-1920{0%,45%{opacity:1}61%,100%{opacity:0}}
@keyframes viz-melt-1980{0%,72%{opacity:1}88%,100%{opacity:0}}
@keyframes viz-lbl-in-2024{0%,72%{opacity:0}88%,100%{opacity:1}}
@keyframes med-sweep{
  0%{transform:translateY(0);opacity:0.8}
  100%{transform:translateY(170px);opacity:0.15}
}
@keyframes med-contour-draw{
  to{stroke-dashoffset:0;fill-opacity:1}
}
@keyframes med-slide-left{to{opacity:1;transform:translateX(0)}}
