Full 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");