{
  "sha": "b39fc6b3b9a0dd03f975f074cf30f2e9a81bb181",
  "redacted": false,
  "message": "Animate classic header wing logo on hover (#407)",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "neo",
    "email": "125815713+nnneeeooo-nnneeeooo@users.noreply.github.com"
  },
  "committer": {
    "login": "web-flow",
    "name": "GitHub",
    "email": "noreply@github.com"
  },
  "created_at": "2026-10-26T15:05:12Z",
  "files": [
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "static/logo-flight.js",
      "previous_filename": "",
      "status": "added",
      "additions": 185,
      "deletions": 0,
      "changes": 185
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 67,
      "deletions": 4,
      "changes": 71
    },
    {
      "filename": "templates/base.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 10,
      "deletions": 1,
      "changes": 11
    },
    {
      "filename": "tests/test_logo_assets.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 5,
      "deletions": 1,
      "changes": 6
    },
    {
      "filename": "tests/test_logo_flight.js",
      "previous_filename": "",
      "status": "added",
      "additions": 112,
      "deletions": 0,
      "changes": 112
    }
  ],
  "diff": "diff --git a/pipeline/build.py b/pipeline/build.py\\nindex d9a152fe6..163571efe 100644\\n--- a/pipeline/build.py\\n+++ b/pipeline/build.py\\n@@ -97,6 +97,7 @@ def static_asset_version() -> str:\\n         \"pixel.css\",\\n         \"fusion-pixel-tw-v2026-10-16.woff2\",\\n         \"app.js\",\\n+        \"logo-flight.js\",\\n         \"text-spacing.js\",\\n         \"search.js\",\\n         \"highlight.js\",\\ndiff --git a/static/logo-flight.js b/static/logo-flight.js\\nnew file mode 100644\\nindex 000000000..87f25c33a\\n--- /dev/null\\n+++ b/static/logo-flight.js\\n@@ -0,0 +1,185 @@\\n+/* A single, user-triggered flight for the approved classic wing mark. */\\n+(function () {\\n+  \"use strict\";\\n+\\n+  var link = document.querySelector(\"[data-brand-flight]\");\\n+  if (!link || !window.matchMedia || !window.requestAnimationFrame) return;\\n+\\n+  var sprite = link.querySelector(\"[data-brand-sprite]\");\\n+  var spriteImage = sprite && sprite.querySelector(\"img\");\\n+  var shell = link.closest(\".brand-shell\");\\n+  var trailSvg = shell && shell.querySelector(\"[data-brand-flight-trail]\");\\n+  var trail = trailSvg && trailSvg.querySelector(\"path\");\\n+  if (!spriteImage || !trail) return;\\n+\\n+  var reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\\n+  var frame = 0;\\n+  var runId = 0;\\n+  var playing = false;\\n+  var duration = 7350;\\n+  var clamp = function (value) { return Math.min(1, Math.max(0, value)); };\\n+  var ease = function (value) {\\n+    var t = clamp(value);\\n+    return t * t * (3 - 2 * t);\\n+  };\\n+  var lerp = function (a, b, t) { return a + (b - a) * t; };\\n+  var cubic = function (a, b, c, d, t) {\\n+    var q = 1 - t;\\n+    return q*q*q*a + 3*q*q*t*b + 3*q*t*t*c + t*t*t*d;\\n+  };\\n+\\n+  function stop() {\\n+    runId++;\\n+    if (frame) window.cancelAnimationFrame(frame);\\n+    frame = 0;\\n+    playing = false;\\n+    link.classList.remove(\"is-flying\");\\n+    sprite.classList.remove(\"is-peeking\", \"is-landing\");\\n+    sprite.removeAttribute(\"data-gaze\");\\n+    sprite.style.transform = \"\";\\n+    sprite.style.removeProperty(\"--brand-effects-opacity\");\\n+    trailSvg.style.opacity = \"\";\\n+    trail.setAttribute(\"d\", \"\");\\n+  }\\n+\\n+  function play() {\\n+    if (playing || reduced.matches || document.documentElement.dataset.theme === \"8bit\" ||\\n+        !spriteImage.complete || !spriteImage.naturalWidth) return;\\n+    stop();\\n+\\n+    var shellBox = shell.getBoundingClientRect();\\n+    var linkBox = link.getBoundingClientRect();\\n+    var spriteBox = sprite.getBoundingClientRect();\\n+    if (!linkBox.width || !spriteBox.width) return;\\n+\\n+    var scale = linkBox.width / 320;\\n+    var rightEdge = Math.min(shellBox.right, shellBox.left + 760);\\n+    var topRightX = rightEdge - linkBox.left - spriteBox.width * .71 - 18;\\n+    var topRightY = shellBox.top + Math.max(20 * scale, 16) - linkBox.top - spriteBox.height / 2;\\n+    var topControlY = Math.min(-40 * scale, topRightY - 18 * scale);\\n+    var lowerLeftX = Math.min(89 * scale, shellBox.width * .2);\\n+    var lowerLeftY = linkBox.height + 33 * scale - spriteBox.height / 2;\\n+    var centerX = linkBox.left - shellBox.left + spriteBox.width / 2;\\n+    var centerY = linkBox.top - shellBox.top + spriteBox.height / 2;\\n+    var points = [];\\n+    var id = runId;\\n+    var started = window.performance.now();\\n+\\n+    trailSvg.setAttribute(\"viewBox\", \"0 0 \" + shellBox.width + \" \" + shellBox.height);\\n+    link.classList.add(\"is-flying\");\\n+    playing = true;\\n+\\n+    function poseAt(ms) {\\n+      var x, y, angle, size, peeking = false, gaze = \"\", landing = false, effects = 1;\\n+      var u, turn;\\n+      if (ms < 430) {\\n+        u = ease(ms / 430);\\n+        x = 9 * scale * u + Math.sin(u * Math.PI * 2) * 3 * scale;\\n+        y = -18 * scale * u - Math.sin(u * Math.PI) * 8 * scale;\\n+        angle = -22 * u;\\n+        size = lerp(1, .42, u);\\n+      } else if (ms < 1850) {\\n+        u = ease((ms - 430) / 1420);\\n+        x = cubic(9*scale, topRightX*.15, topRightX*.65, topRightX, u);\\n+        y = cubic(-18*scale, topControlY, topRightY + 38*scale, topRightY, u);\\n+        y += Math.sin(u * Math.PI * 4) * 9 * scale * Math.sin(u * Math.PI);\\n+        angle = lerp(-22, 14, u) + Math.sin(u * Math.PI * 3) * 17;\\n+        size = .42 + Math.sin(u * Math.PI * 2) * .025;\\n+      } else if (ms < 2850) {\\n+        u = ease((ms - 1850) / 1000);\\n+        turn = u * Math.PI * 2;\\n+        x = topRightX + 23 * scale * (Math.cos(turn) - 1);\\n+        y = topRightY + 15 * scale * Math.sin(turn);\\n+        angle = 14 + 360 * u;\\n+        size = .42;\\n+        gaze = u < .5 ? \"right\" : \"left\";\\n+      } else if (ms < 3250) {\\n+        u = (ms - 2850) / 400;\\n+        var float = Math.sin(Math.PI * u);\\n+        x = topRightX + Math.sin(u * Math.PI * 2) * float * 3 * scale;\\n+        y = topRightY - float * 3 * scale;\\n+        angle = 374 + Math.sin(u * Math.PI * 2) * float * 10;\\n+        size = .42;\\n+        peeking = true;\\n+        gaze = u < .52 ? \"right\" : \"left\";\\n+      } else if (ms < 4600) {\\n+        u = ease((ms - 3250) / 1350);\\n+        x = cubic(topRightX, topRightX + 16*scale, lowerLeftX + 170*scale, lowerLeftX, u);\\n+        y = cubic(topRightY, topRightY + 103*scale, lowerLeftY - 43*scale, lowerLeftY, u);\\n+        x += Math.sin(u * Math.PI * 3) * 9 * scale * Math.sin(u * Math.PI);\\n+        angle = lerp(374, 546, u) + Math.sin(u * Math.PI * 2) * 12;\\n+        size = .42;\\n+      } else if (ms < 5350) {\\n+        u = ease((ms - 4600) / 750);\\n+        turn = u * Math.PI * 2;\\n+        x = lowerLeftX + 16 * scale * Math.sin(2 * turn);\\n+        y = lowerLeftY + 10 * scale * Math.sin(turn);\\n+        angle = 546 + Math.sin(turn) * 28;\\n+        size = .42;\\n+        peeking = true;\\n+        gaze = u < .5 ? \"left\" : \"right\";\\n+      } else if (ms < 6600) {\\n+        u = ease((ms - 5350) / 1250);\\n+        x = cubic(lowerLeftX, lowerLeftX + 150*scale, -42*scale, 12*scale, u);\\n+        y = cubic(lowerLeftY, lowerLeftY + 24*scale, -70*scale, -9*scale, u);\\n+        y += Math.sin(u * Math.PI * 3) * 7 * scale * Math.sin(u * Math.PI);\\n+        angle = lerp(546, 720, u);\\n+        size = .42 + .30 * ease((u - .45) / .55);\\n+      } else {\\n+        var q = clamp((ms - 6600) / 750);\\n+        u = ease(q);\\n+        x = 12 * scale * (1 - u);\\n+        y = -9 * scale * (1 - u);\\n+        angle = 720 + Math.sin(q * Math.PI) * (1 - u) * 3;\\n+        size = .72 + .28 * u + .022 * Math.sin(q * Math.PI) * u;\\n+        effects = 1 - ease((q - .30) / .70);\\n+        landing = true;\\n+      }\\n+      return { x: x, y: y, angle: angle, size: size, peeking: peeking,\\n+        gaze: gaze, landing: landing, effects: effects };\\n+    }\\n+\\n+    function draw(now) {\\n+      if (id !== runId) return;\\n+      var ms = Math.min(duration, now - started);\\n+      var pose = poseAt(ms);\\n+      sprite.style.transform = \"translate(\" + pose.x.toFixed(2) + \"px, \" +\\n+        pose.y.toFixed(2) + \"px) rotate(\" + pose.angle.toFixed(2) +\\n+        \"deg) scale(\" + pose.size.toFixed(3) + \")\";\\n+      sprite.classList.toggle(\"is-peeking\", pose.peeking);\\n+      sprite.classList.toggle(\"is-landing\", pose.landing);\\n+      sprite.style.setProperty(\"--brand-effects-opacity\", pose.effects.toFixed(3));\\n+      trailSvg.style.opacity = pose.effects.toFixed(3);\\n+      if (pose.gaze) sprite.setAttribute(\"data-gaze\", pose.gaze);\\n+      else sprite.removeAttribute(\"data-gaze\");\\n+\\n+      if (!pose.peeking) {\\n+        points.push([centerX + pose.x, centerY + pose.y]);\\n+        if (points.length > 30) points.shift();\\n+      } else if (points.length > 1) points.shift();\\n+      trail.setAttribute(\"d\", points.length > 1\\n+        ? \"M \" + points.map(function (point) {\\n+          return point[0].toFixed(1) + \" \" + point[1].toFixed(1);\\n+        }).join(\" L \")\\n+        : \"\");\\n+      if (ms < duration) frame = window.requestAnimationFrame(draw);\\n+      else stop();\\n+    }\\n+\\n+    frame = window.requestAnimationFrame(draw);\\n+  }\\n+\\n+  link.addEventListener(\"pointerenter\", function (event) {\\n+    if (event.pointerType === \"mouse\" || event.pointerType === \"pen\") play();\\n+  });\\n+  window.addEventListener(\"resize\", stop);\\n+  document.addEventListener(\"visibilitychange\", function () {\\n+    if (document.hidden) stop();\\n+  });\\n+  if (reduced.addEventListener) reduced.addEventListener(\"change\", function () {\\n+    if (reduced.matches) stop();\\n+  });\\n+  new MutationObserver(function () {\\n+    if (document.documentElement.dataset.theme === \"8bit\") stop();\\n+  }).observe(document.documentElement, { attributes: true, attributeFilter: [\"data-theme\"] });\\n+})();\\ndiff --git a/static/site.css b/static/site.css\\nindex ce0598ab5..248dea933 100644\\n--- a/static/site.css\\n+++ b/static/site.css\\n@@ -65,14 +65,77 @@ a.ticker-item:hover .ticker-time { color: var(--color-accent); }\\n .brand-link {\\n   display: inline-flex; align-items: center; color: inherit;\\n   text-decoration: none; flex: 0 0 320px; min-width: 250px;\\n-  /* iOS in-app browsers briefly use the object's 220px HTML height while\\n-     its external SVG is loading. Pin the frame ratio before that happens. */\\n-  aspect-ratio: 50 / 11; overflow: hidden;\\n+  /* Keep the logo frame stable while the SVG loads, with room for its flight. */\\n+  aspect-ratio: 50 / 11; position: relative; overflow: visible;\\n }\\n .brand-logo {\\n   display: block; width: 100%; height: 100%; max-width: 320px;\\n   object-fit: contain; background: transparent;\\n }\\n+.brand-link.is-flying { z-index: 3; }\\n+.brand-link.is-flying .brand-logo { clip-path: inset(0 0 0 23.5%); }\\n+.brand-flight-sprite {\\n+  position: absolute; top: 0; left: 0; width: 25%; height: 100%;\\n+  visibility: hidden; pointer-events: none;\\n+  transform-origin: 50% 50%; will-change: transform;\\n+}\\n+.brand-link.is-flying .brand-flight-sprite { visibility: visible; }\\n+.brand-flight-crop { position: absolute; inset: 0; overflow: hidden; }\\n+.brand-flight-crop img {\\n+  display: block; width: 400%; height: 100%; max-width: none;\\n+  object-fit: contain; object-position: left top;\\n+}\\n+.brand-flight-eyes {\\n+  position: absolute; left: 39%; top: 39%; display: none;\\n+  align-items: center; gap: 3px; pointer-events: none;\\n+  opacity: var(--brand-effects-opacity, 1);\\n+}\\n+.brand-flight-eyes i {\\n+  position: relative; display: block; width: 8px; height: 9px;\\n+  border-radius: 50%; background: var(--color-bg);\\n+  box-shadow: 0 0 0 1px var(--color-text);\\n+}\\n+.brand-flight-eyes i::after {\\n+  content: \"\"; position: absolute; top: 3px; left: 3px;\\n+  width: 3px; height: 3px; border-radius: 50%;\\n+  background: var(--color-text); transition: transform .12s ease;\\n+}\\n+.brand-link.is-flying .brand-flight-eyes { display: flex; }\\n+.brand-flight-sprite[data-gaze=\"right\"] .brand-flight-eyes i::after { transform: translateX(3px); }\\n+.brand-flight-sprite[data-gaze=\"left\"] .brand-flight-eyes i::after { transform: translateX(-2px); }\\n+.brand-flight-sprite.is-peeking .brand-flight-eyes i { animation: brand-flight-blink 1.1s ease-in-out infinite; }\\n+.brand-flight-spark {\\n+  position: absolute; display: none; width: 5px; height: 5px;\\n+  border-radius: 50%; background: var(--color-accent-2);\\n+  box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent-2) 16%, transparent);\\n+  animation: brand-flight-twinkle .28s ease-in-out infinite alternate;\\n+}\\n+.brand-flight-spark-a { left: -7%; top: 36%; }\\n+.brand-flight-spark-b { left: 9%; top: 83%; width: 4px; height: 4px; animation-delay: .13s; }\\n+.brand-flight-spark-c { left: 42%; top: -8%; width: 3px; height: 3px; animation-delay: .23s; }\\n+.brand-link.is-flying .brand-flight-spark { display: block; }\\n+.brand-flight-sprite.is-landing .brand-flight-spark {\\n+  animation: none; opacity: var(--brand-effects-opacity, 1);\\n+}\\n+.brand-flight-trail {\\n+  position: absolute; inset: 0; display: block; width: 100%; height: 100%;\\n+  pointer-events: none; overflow: visible; z-index: 2;\\n+}\\n+.brand-flight-trail path {\\n+  fill: none; stroke: var(--color-accent-2); stroke-width: 2;\\n+  stroke-linecap: round; stroke-dasharray: 2 7; opacity: .48;\\n+}\\n+@keyframes brand-flight-twinkle {\\n+  from { opacity: .2; transform: scale(.6); }\\n+  to { opacity: 1; transform: scale(1.2); }\\n+}\\n+@keyframes brand-flight-blink {\\n+  0%, 42%, 52%, 100% { transform: scaleY(1); }\\n+  47% { transform: scaleY(.12); }\\n+}\\n+@media (prefers-reduced-motion: reduce) {\\n+  .brand-flight-spark, .brand-flight-eyes i { animation: none !important; }\\n+}\\n .footer-logo {\\n   display: block; width: min(100%, 300px); height: auto;\\n   background: transparent;\\n@@ -145,7 +208,7 @@ a.ticker-item:hover .ticker-time { color: var(--color-accent); }\\n .utility-date, .utility-clock { letter-spacing: .06em; }\\n .utility-clock { font-weight: 600; }\\n .utility-update { margin-left: auto; display: flex; align-items: center; gap: 7px; }\\n-.brand-shell { border-bottom: 2px solid var(--color-divider); }\\n+.brand-shell { position: relative; border-bottom: 2px solid var(--color-divider); }\\n .brand-row {\\n   padding: 8px 20px; display: flex; align-items: center;\\n   gap: clamp(36px, 4vw, 56px);\\ndiff --git a/templates/base.html b/templates/base.html\\nindex 83210c08c..1b5627ffd 100644\\n--- a/templates/base.html\\n+++ b/templates/base.html\\n@@ -63,6 +63,7 @@ gtag('config', '{{ ga4_measurement_id }}', {\\n       title=\"{{ t.siteName }} — {{ t.latest }}\" href=\"{{ feed_url }}\">\\n {% block head_extra %}{% endblock %}\\n <script src=\"{{ base }}/assets/app.js?v={{ asset_version }}\" defer></script>\\n+<script src=\"{{ base }}/assets/logo-flight.js?v={{ asset_version }}\" defer></script>\\n <script src=\"{{ base }}/assets/text-spacing.js?v={{ asset_version }}\" defer></script>\\n </head>\\n <body>\\n@@ -129,13 +130,20 @@ gtag('config', '{{ ga4_measurement_id }}', {\\n   </div>\\n   <div class=\"brand-shell\">\\n     <div class=\"brand-row\">\\n-      <a class=\"brand-link\" href=\"{{ home_url }}\" aria-label=\"SKYTICAL — {{ t.homeLabel }}\">\\n+      <a class=\"brand-link\" href=\"{{ home_url }}\" aria-label=\"SKYTICAL — {{ t.homeLabel }}\" data-brand-flight>\\n         <object class=\"brand-logo\" data=\"{{ base }}/assets/skytical-logo.svg?v={{ asset_version }}\"\\n                 type=\"image/svg+xml\" aria-hidden=\"true\" tabindex=\"-1\"\\n                 width=\"1000\" height=\"220\" style=\"pointer-events:none;\">\\n           <img src=\"{{ base }}/assets/skytical-logo.svg?v={{ asset_version }}\"\\n                alt=\"\" width=\"1000\" height=\"220\">\\n         </object>\\n+        <span class=\"brand-flight-sprite\" data-brand-sprite aria-hidden=\"true\">\\n+          <span class=\"brand-flight-crop\"><img src=\"{{ base }}/assets/skytical-logo.svg?v={{ asset_version }}\" alt=\"\" width=\"1000\" height=\"220\"></span>\\n+          <span class=\"brand-flight-eyes\"><i></i><i></i></span>\\n+          <span class=\"brand-flight-spark brand-flight-spark-a\"></span>\\n+          <span class=\"brand-flight-spark brand-flight-spark-b\"></span>\\n+          <span class=\"brand-flight-spark brand-flight-spark-c\"></span>\\n+        </span>\\n       </a>\\n       <a class=\"pixel-brand\" href=\"{{ home_url }}\" aria-label=\"SKYTICΛL — {{ t.homeLabel }}\">\\n         <img src=\"{{ base }}/assets/skytical-logo-8bit-dark.svg?v={{ asset_version }}\"\\n@@ -152,6 +160,7 @@ gtag('config', '{{ ga4_measurement_id }}', {\\n         {% endfor %}\\n       </nav>\\n     </div>\\n+    <svg class=\"brand-flight-trail\" data-brand-flight-trail aria-hidden=\"true\"><path></path></svg>\\n   </div>\\n   {% if ticker_items %}\\n   <div class=\"ticker-shell\">\\ndiff --git a/tests/test_logo_assets.py b/tests/test_logo_assets.py\\nindex cdf0729de..6de054d6b 100644\\n--- a/tests/test_logo_assets.py\\n+++ b/tests/test_logo_assets.py\\n@@ -87,7 +87,11 @@ def main() -> None:\\n     assert wing_top == red_top == lockup[\"wordmarkOrigin\"][1]\\n \\n     assert png_dimensions(social_path) == (1200, 630)\\n-    assert home.count(f'/assets/skytical-logo.svg?v={build.ASSET_VERSION}') >= 2\\n+    assert home.count(f'/assets/skytical-logo.svg?v={build.ASSET_VERSION}') >= 3\\n+    assert 'data-brand-flight' in home\\n+    assert 'data-brand-sprite' in home\\n+    assert f'/assets/logo-flight.js?v={build.ASSET_VERSION}' in home\\n+    assert (ROOT / \"site\" / \"assets\" / \"logo-flight.js\").is_file()\\n     assert f'/assets/skytical-mark.svg?v={build.ASSET_VERSION}' in home\\n     assert f'/assets/skytical-social.png?v={build.ASSET_VERSION}' in home\\n     assert \"/assets/logo-fix.css\" not in home\\ndiff --git a/tests/test_logo_flight.js b/tests/test_logo_flight.js\\nnew file mode 100644\\nindex 000000000..6333525eb\\n--- /dev/null\\n+++ b/tests/test_logo_flight.js\\n@@ -0,0 +1,112 @@\\n+/* Verify the header logo's hover journey without a browser dependency. */\\n+const assert = require(\"node:assert/strict\");\\n+const fs = require(\"node:fs\");\\n+const path = require(\"node:path\");\\n+const vm = require(\"node:vm\");\\n+\\n+const source = fs.readFileSync(path.join(__dirname, \"../static/logo-flight.js\"), \"utf8\");\\n+\\n+function setup(width, reducedMotion = false, theme = \"light\") {\\n+  let now = 0;\\n+  let next = null;\\n+  function element(rect = {}) {\\n+    const listeners = {};\\n+    const attributes = {};\\n+    const classes = new Set();\\n+    const style = {\\n+      setProperty(name, value) { this[name] = value; },\\n+      removeProperty(name) { delete this[name]; },\\n+    };\\n+    return {\\n+      listeners, attributes, style,\\n+      classList: {\\n+        add(...names) { names.forEach(name => classes.add(name)); },\\n+        remove(...names) { names.forEach(name => classes.delete(name)); },\\n+        toggle(name, enabled) { if (enabled) classes.add(name); else classes.delete(name); },\\n+        contains(name) { return classes.has(name); },\\n+      },\\n+      addEventListener(name, handler) { listeners[name] = handler; },\\n+      setAttribute(name, value) { attributes[name] = value; },\\n+      removeAttribute(name) { delete attributes[name]; },\\n+      getBoundingClientRect() { return rect; },\\n+    };\\n+  }\\n+  const brandWidth = Math.min(320, width - 36);\\n+  const brandHeight = brandWidth * .22;\\n+  const shell = element({ left: 0, top: 0, right: width, bottom: 86, width, height: 86 });\\n+  const link = element({ left: 18, top: 8, right: 18 + brandWidth,\\n+    bottom: 8 + brandHeight, width: brandWidth, height: brandHeight });\\n+  const sprite = element({ left: 18, top: 8, right: 18 + brandWidth / 4,\\n+    bottom: 8 + brandHeight, width: brandWidth / 4, height: brandHeight });\\n+  const trailSvg = element();\\n+  const trail = element();\\n+  link.querySelector = () => sprite;\\n+  sprite.querySelector = () => ({ complete: true, naturalWidth: 1000 });\\n+  link.closest = () => shell;\\n+  shell.querySelector = () => trailSvg;\\n+  trailSvg.querySelector = () => trail;\\n+  const root = { dataset: { theme } };\\n+  const document = {\\n+    documentElement: root, hidden: false, listeners: {},\\n+    querySelector: () => link,\\n+    addEventListener(name, handler) { this.listeners[name] = handler; },\\n+  };\\n+  const reduced = { matches: reducedMotion, addEventListener() {} };\\n+  const window = {\\n+    matchMedia: () => reduced,\\n+    performance: { now: () => now },\\n+    requestAnimationFrame(handler) { next = handler; return 1; },\\n+    cancelAnimationFrame() { next = null; },\\n+    listeners: {},\\n+    addEventListener(name, handler) { this.listeners[name] = handler; },\\n+  };\\n+  vm.runInNewContext(source, { document, window, MutationObserver: class {\\n+    observe() {}\\n+  } });\\n+  return {\\n+    link, sprite, trail, root, window, document,\\n+    hover(pointerType = \"mouse\") { link.listeners.pointerenter({ pointerType }); },\\n+    tick(time) {\\n+      now = time;\\n+      assert.ok(next, \"animation ended before \" + time);\\n+      next(time);\\n+      const match = (sprite.style.transform || \"\").match(\\n+        /translate\\(([-\\d.]+)px, ([-\\d.]+)px\\) rotate\\(([-\\d.]+)deg\\) scale\\(([-\\d.]+)\\)/\\n+      );\\n+      return match && { x: +match[1], y: +match[2], angle: +match[3], size: +match[4] };\\n+    },\\n+  };\\n+}\\n+\\n+for (const width of [320, 736, 1200]) {\\n+  const ui = setup(width);\\n+  ui.hover(\"touch\");\\n+  assert.equal(ui.link.classList.contains(\"is-flying\"), false);\\n+  ui.hover();\\n+  assert.equal(ui.link.classList.contains(\"is-flying\"), true);\\n+  const tiny = ui.tick(430);\\n+  assert.ok(Math.abs(tiny.size - .42) < .002);\\n+  const top = ui.tick(1850);\\n+  const orbit = ui.tick(2350);\\n+  assert.ok(Math.abs(top.x - orbit.x) > 10, \"upper corner must have a visible loop\");\\n+  ui.tick(4600);\\n+  const lower = ui.tick(4788);\\n+  const lowerOrbit = ui.tick(4975);\\n+  assert.ok(Math.abs(lower.x - lowerOrbit.x) > 5, \"lower corner must have a visible loop\");\\n+  ui.tick(6600);\\n+  const nearHome = ui.tick(7349);\\n+  assert.ok(Math.abs(nearHome.x) < .1 && Math.abs(nearHome.y) < .1);\\n+  assert.ok(Math.abs(nearHome.size - 1) < .003, \"landing must reach original size smoothly\");\\n+  ui.tick(7350);\\n+  assert.equal(ui.link.classList.contains(\"is-flying\"), false);\\n+  assert.equal(ui.sprite.style.transform, \"\");\\n+  assert.equal(ui.trail.attributes.d, \"\");\\n+}\\n+\\n+for (const [reducedMotion, theme] of [[true, \"light\"], [false, \"8bit\"]]) {\\n+  const ui = setup(736, reducedMotion, theme);\\n+  ui.hover();\\n+  assert.equal(ui.link.classList.contains(\"is-flying\"), false);\\n+}\\n+\\n+console.log(\"test_logo_flight: OK\");"
}
