diff --git a/src/app/globals.css b/src/app/globals.css
index 0bd857c..70a14df 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1215,12 +1215,18 @@ body {
}
}
-/* Global Desktop Hero Formatting (Matches live site) */
+/* Global Desktop Hero Formatting (Matches live site)
+ .miletruth-hero-container is deliberately excluded: unlike the other
+ hero wrappers it IS the visual card itself (background-image + a
+ flat bottom edge that the stats bar overlaps by -25px), not a
+ wrapper around a separate inner card, so it carries its own
+ correctly-tuned margin-top/padding/border-radius in MileTruthHero.tsx.
+ Including it here clobbered that with the wrapper values, collapsing
+ its top gap to 8px and its internal padding to 20px. */
@media (min-width: 1025px) {
/* Give every hero wrapper a consistent gap from the top and sides */
.elementor-element.elementor-element-741f56c,
- .custom-standard-hero-container,
- .miletruth-hero-container {
+ .custom-standard-hero-container {
padding: 20px 24px !important;
margin-top: 8px !important;
}
@@ -1229,8 +1235,7 @@ body {
.elementor-element.elementor-element-741f56c .owl-carousel.owl-theme .content-item,
.elementor-element.elementor-element-741f56c .content-item,
.custom-standard-hero-card,
- .miletruth-hero .slide-content,
- .miletruth-hero .miletruth-hero-container {
+ .miletruth-hero .slide-content {
border-radius: 24px !important;
overflow: hidden !important;
}
diff --git a/src/components/sections/MileTruthHero.tsx b/src/components/sections/MileTruthHero.tsx
index d568688..c951e75 100644
--- a/src/components/sections/MileTruthHero.tsx
+++ b/src/components/sections/MileTruthHero.tsx
@@ -68,7 +68,7 @@ export default function MileTruthHero() {
flex-direction: column;
justify-content: center;
border-radius: 25px 25px 0 0;
- margin-top: 20px;
+ margin-top: 28px;
padding: 100px 0;
}
.miletruth-hero-container::before {
diff --git a/src/components/sections/WingsFeatureGrid.tsx b/src/components/sections/WingsFeatureGrid.tsx
index 2bbb972..8500116 100644
--- a/src/components/sections/WingsFeatureGrid.tsx
+++ b/src/components/sections/WingsFeatureGrid.tsx
@@ -142,6 +142,11 @@ export default function WingsFeatureGrid() {
@media (max-width: 600px) {
.wfg-grid { grid-template-columns: 1fr; }
+ /* var(--space-section-lg) floors at a fixed 52px on any phone
+ width (clamp() has no further breakpoint-based step-down), which
+ reads as noticeably heavier than the section rhythm near the
+ footer. Trimmed to a real mobile value here instead. */
+ .wfg-section { padding-top: 44px; padding-bottom: 44px; }
}
`,
}}
diff --git a/src/components/sections/WingsFinalCTA.tsx b/src/components/sections/WingsFinalCTA.tsx
index 6015982..2a66a21 100644
--- a/src/components/sections/WingsFinalCTA.tsx
+++ b/src/components/sections/WingsFinalCTA.tsx
@@ -55,12 +55,28 @@ export default function WingsFinalCTA() {
@media (max-width: 768px) {
.wfcta-container {
- margin-top: 40px !important;
- margin-bottom: 40px !important;
+ /* Raised to dominate the collapsed margin with the previous
+ section (Roadmap's own 24px bottom margin) so the gap above
+ the CTA card reads the same as the gap below it. */
+ margin-top: 14px !important;
+ /* There's ~110px of unexplained extra space rendered between this
+ container and the Footer that follows on mobile (verified with
+ Playwright — no element/padding/margin in the DOM accounts for
+ it, so it isn't coming from either section's own box). Pulling
+ the CTA up with a negative bottom margin closes most of that
+ gap directly instead of chasing the underlying cause, without
+ touching the shared Footer component (used on every page) or
+ either card's internal padding. Tuned so the resulting visible
+ gap below the card matches the ~44px gap above it. */
+ margin-bottom: -96px !important;
padding: 0 10px !important;
}
.wfcta-box {
- padding: 48px 20px !important;
+ /* Vertical padding cut ~25% (48px -> 36px) to close the excessive
+ gap above the heading and below the button on mobile — the
+ card felt loose relative to the rest of the Wings page.
+ Horizontal padding/border-radius/width untouched. */
+ padding: 36px 20px !important;
border-radius: 22px !important;
}
.wfcta-title {
diff --git a/src/components/sections/WingsFleetStrategy.tsx b/src/components/sections/WingsFleetStrategy.tsx
index 12e15d1..695a49a 100644
--- a/src/components/sections/WingsFleetStrategy.tsx
+++ b/src/components/sections/WingsFleetStrategy.tsx
@@ -49,6 +49,20 @@ const FEATURES: EVFeature[] = [
export default function WingsFleetStrategy() {
return (
+ {/* EVSection is shared across other pages (Workflow1/Workflow2 etc.),
+ so its own .evnd-gap-bottom rule isn't touched — this override is
+ scoped to #wings-fleet only, and reduces just the blank margin
+ immediately before Infrastructure by ~42% (24px -> 14px) on
+ mobile, matching the site's tighter mobile rhythm elsewhere. */}
+
(null);
+ const svgRef = useRef(null);
+ const pathRef = useRef(null);
+ // A second copy of the same "d", drawn brighter/thicker/blurred. A moving
+ // dasharray window (kept in sync with the pulse's own progress, see
+ // onUpdate below) makes a soft, feathered segment of the route glow right
+ // under the pulse and fade back to the resting line just behind it — the
+ // route being "energized" as the pulse passes, not just a static line.
+ const illumRef = useRef(null);
+ // The traveler is the primary visual: a bright optical pulse riding the
+ // route. It's the outer group MotionPathPlugin translates. The aircraft is
+ // nested inside it as a small, secondary glyph — never its own focal point.
+ const travelerRef = useRef(null);
+ // Only this inner group rotates to face the direction of travel (the
+ // halo). Kept separate from travelerRef with an explicit 0,0 transform
+ // origin so rotation never pivots around a fill-box-derived point.
+ const directionalRef = useRef(null);
+ // The aircraft's own rotation wrapper, painted as a sibling *after* the
+ // core circle (see JSX) so its silhouette stays readable on top of the
+ // bright core instead of being buried under it. Mirrors directionalRef's
+ // rotation exactly (set alongside it in onUpdate) so both halves of the
+ // traveler always face the same way despite being separate DOM branches.
+ const aircraftDirectionalRef = useRef(null);
+ const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
+ const iconRefs = useRef<(HTMLDivElement | null)[]>([]);
+
+ useEffect(() => {
+ const wrap = wrapRef.current;
+ const svg = svgRef.current;
+ const path = pathRef.current;
+ const illum = illumRef.current;
+ const traveler = travelerRef.current;
+ const directional = directionalRef.current;
+ const aircraftDirectional = aircraftDirectionalRef.current;
+ if (!wrap || !svg || !path || !illum || !traveler || !directional || !aircraftDirectional) return;
+
+ // Declared here (not inside the gsap.context callback below) so the
+ // cleanup function — which must run on every unmount, including ones
+ // gsap.context() itself doesn't know about, like these plain rAF/
+ // ResizeObserver handles — can always reach them.
+ let resizeRaf = 0;
+ let initialRaf = 0;
+ let rafIn = 0;
+ let ro: ResizeObserver | undefined;
+ let loadHandler: (() => void) | undefined;
+ // Also assigned (not re-declared) inside the gsap.context callback below,
+ // for the same reason: cleanup needs to reach them even though
+ // ctx.revert() already kills anything the context tracked, since the
+ // ScrollTrigger is created synchronously (tracked) but the flight
+ // timeline is only created later, asynchronously, once onEnter actually
+ // fires (not reliably tracked by the context in that case).
+ let st: ScrollTrigger | null = null;
+ let playedTimeline: gsap.core.Timeline | null = null;
+
+ // Everything GSAP-related (ScrollTrigger, timelines, gsap.set calls) is
+ // created inside this context so a single ctx.revert() on unmount kills
+ // all of it — this is what makes the whole setup safe to re-run on every
+ // mount (initial load, client-side route navigation, back/forward, hot
+ // reload) instead of only working after a hard refresh, where a prior
+ // mount's leftover ScrollTrigger/timeline could otherwise linger or hold
+ // a stale measurement.
+ const ctx = gsap.context(() => {
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ let played = false;
+
+ const isMobileLayout = () => window.matchMedia(`(max-width: ${MOBILE_BQ}px)`).matches;
+
+ const measure = (): { points: Point[]; isMobile: boolean } => {
+ const wrapRect = wrap.getBoundingClientRect();
+ const isMobile = isMobileLayout();
+
+ const points = cardRefs.current
+ .map((card, i) => {
+ const icon = iconRefs.current[i];
+ if (!card || !icon) return null;
+ const cardRect = card.getBoundingClientRect();
+ const iconRect = icon.getBoundingClientRect();
+ if (isMobile) {
+ return {
+ x: 12,
+ y: iconRect.top + iconRect.height / 2 - wrapRect.top,
+ };
+ }
+ return {
+ x: iconRect.left + iconRect.width / 2 - wrapRect.left,
+ y: cardRect.top - wrapRect.top - 14,
+ };
+ })
+ .filter((p): p is Point => p !== null);
+
+ svg.setAttribute("width", String(wrapRect.width));
+ svg.setAttribute("height", String(wrapRect.height));
+ svg.setAttribute("viewBox", `0 0 ${wrapRect.width} ${wrapRect.height}`);
+
+ // Wave only applies to the horizontal (non-mobile) layout — mobile
+ // keeps its existing straight vertical line unchanged. Amplitude
+ // scales with the section's width so it stays "gentle" (never a
+ // straight line, never overly aggressive) at any breakpoint.
+ const amplitude = isMobile
+ ? 0
+ : Math.max(WAVE_AMPLITUDE_MIN, Math.min(WAVE_AMPLITUDE_MAX, wrapRect.width / 20));
+ const routePoints = isMobile ? points : buildWavePoints(points, amplitude);
+ const d = buildPathD(routePoints);
+ path.setAttribute("d", d);
+ illum.setAttribute("d", d);
+
+ return { points, isMobile };
+ };
+
+ // Keep the (static, or already-settled) line in sync with layout reflow.
+ // Only ever updates geometry — never restarts or replays the animation.
+ ro = new ResizeObserver(() => {
+ cancelAnimationFrame(resizeRaf);
+ resizeRaf = requestAnimationFrame(measure);
+ });
+ ro.observe(wrap);
+
+ // Initial synchronous measurement so `path` has real geometry before any
+ // gsap.set/timeline touches it (ResizeObserver's first callback is async).
+ // Followed by one deferred re-measure: StaggerChildren's own mount effect
+ // (a sibling/child effect) can still have cards translated by their
+ // pre-reveal yOffset at this exact tick, so the very first synchronous
+ // read can be transiently stale — a post-paint rAF corrects it.
+ measure();
+ initialRaf = requestAnimationFrame(() => {
+ measure();
+ // Recalculates every ScrollTrigger's start/end position against the
+ // now-settled layout. Without this, a trigger created a moment before
+ // web fonts/images finish shifting the page can end up with a stale
+ // "top 85%" position that a normal scroll never actually crosses —
+ // the classic reason a GSAP ScrollTrigger works after a hard refresh
+ // (cache-warm, layout already stable) but not after a fresh client-side
+ // navigation.
+ ScrollTrigger.refresh();
+ });
+
+ // Extra safety net: images loading in after this point can still shift
+ // layout. One more refresh once the whole page (including images) has
+ // finished loading catches that — a no-op if `load` already fired.
+ if (document.readyState !== "complete") {
+ loadHandler = () => ScrollTrigger.refresh();
+ window.addEventListener("load", loadHandler, { once: true });
+ }
+
+ const activateCard = (i: number) => {
+ const card = cardRefs.current[i];
+ const icon = iconRefs.current[i];
+ if (!card) return;
+ // Arrival: border gently strengthens, ambient glow grows, icon
+ // brightens, card lifts a touch (CSS-driven, eased over ~0.5s — see
+ // .wwg-card / .wwg-card-glow transition durations below — so it grows
+ // and settles rather than snapping on).
+ card.classList.add("wwg-card-flown", "wwg-card-pulse");
+ // Icon's own gentle scale pulse starts a beat after the arrival
+ // begins, so the sequence reads as "arrives, then responds" rather
+ // than everything happening at once.
+ if (icon) {
+ gsap.timeline({ delay: 0.28 })
+ .to(icon, { scale: 1.02, duration: 0.32, ease: "expo.out" })
+ .to(icon, { scale: 1, duration: 0.32, ease: "sine.inOut" });
+ }
+ // Hold the arrival state, then ease into the subtler, persistent
+ // "flown" resting state.
+ gsap.delayedCall(0.95, () => card.classList.remove("wwg-card-pulse"));
+ };
+
+ const runSequence = () => {
+ if (played) return;
+ played = true;
+
+ const { points, isMobile } = measure();
+ if (points.length < 2) return;
+
+ const restOpacity = isMobile ? 0.15 : 0.18;
+ const total = path.getTotalLength();
+ // The illuminated window trailing the pulse: roughly a fifth of the
+ // route's length, so it reads as a genuine glowing trail rather than
+ // a small blip or the entire line at once.
+ const trailLen = total * 0.22;
+ const illumDashGap = total + 200;
+ illum.setAttribute("stroke-dasharray", `${trailLen} ${illumDashGap}`);
+ const fractions = points.map((pt) => {
+ let best = 0;
+ let bestDist = Infinity;
+ const step = Math.max(2, total / 400);
+ for (let l = 0; l <= total; l += step) {
+ const p = path.getPointAtLength(l);
+ const dist = (p.x - pt.x) ** 2 + (p.y - pt.y) ** 2;
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = l;
+ }
+ }
+ return best / total;
+ });
+ const fired = fractions.map(() => false);
+
+ let currentAngle = 0;
+ let angleInit = false;
+
+ const tl = gsap.timeline();
+ playedTimeline = tl;
+
+ // The energy pulse's own journey — calm and deliberate, ~4.8-5.5s so
+ // it glides rather than rushes from the first card to the last.
+ const FLIGHT_DURATION = 5.2;
+ // The route draws slightly faster than the pulse travels, both
+ // starting together at t=0 — so the drawn extent always leads the
+ // pulse's position (never the other way round) and the whole route
+ // is guaranteed fully illuminated well before the pulse's own journey
+ // ends, while both still read as one continuous, synchronized motion.
+ const DRAW_DURATION = FLIGHT_DURATION * 0.9;
+
+ if (hasDrawSVG) {
+ tl.to(path, { drawSVG: "100%", duration: DRAW_DURATION, ease: "power2.inOut" }, 0);
+ } else {
+ gsap.set(path, { strokeDasharray: total, strokeDashoffset: total });
+ tl.to(path, { strokeDashoffset: 0, duration: DRAW_DURATION, ease: "power2.inOut" }, 0);
+ }
+ // The base route ramps straight to its final resting opacity as it
+ // draws — never to an elevated "traveling" brightness — so there is
+ // only ever one illuminated layer on screen: the bright illum trail
+ // riding with the pulse (below). Without this, the whole drawn route
+ // would sit at a visibly elevated opacity for the entire flight,
+ // reading as a second, competing "lit" route behind the pulse.
+ tl.to(path, { opacity: restOpacity, duration: 0.9, ease: "sine.out" }, 0);
+
+ // GSAP computes its own rotation pivot for SVG elements from the
+ // target's bounding box (stamped as a data-svg-origin attribute) and
+ // ignores plain CSS transform-origin for that purpose — so without
+ // this, both directional groups were actually rotating around their
+ // bbox corner instead of (0,0), visibly orbiting off the path's
+ // centerline as they turned. svgOrigin is GSAP's own equivalent,
+ // stated in the element's literal SVG coordinates (not bbox-relative
+ // percentages), and only needs setting once — the per-frame
+ // rotation-only gsap.set calls below then reuse it automatically.
+ gsap.set([directional, aircraftDirectional], { svgOrigin: "0 0" });
+
+ // A soft fade-in rather than an instant pop, so the pulse's arrival
+ // reads as one continuous gesture from the very first frame.
+ tl.to(traveler, { opacity: 1, duration: 0.3, ease: "sine.out" }, 0);
+ tl.to(illum, { opacity: 1, duration: 0.4, ease: "sine.out" }, 0);
+ tl.to(traveler, {
+ // alignOrigin locks the traveler's own center (not a default
+ // corner/bbox reference) to the path, matching the explicit
+ // transform-origin: 50% 50% above — keeps the pulse/aircraft
+ // precisely on the route's centerline with no vertical drift.
+ motionPath: { path, autoRotate: false, alignOrigin: [0.5, 0.5] },
+ duration: FLIGHT_DURATION,
+ ease: "power2.inOut",
+ onUpdate: function () {
+ const p = this.progress();
+ const l = p * total;
+ const eps = Math.max(1, total * 0.01);
+ const a = path.getPointAtLength(Math.max(0, l - eps));
+ const b = path.getPointAtLength(Math.min(total, l + eps));
+ const target = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI + AIRCRAFT_ROTATION_OFFSET;
+
+ if (!angleInit) {
+ currentAngle = target;
+ angleInit = true;
+ } else {
+ let delta = target - currentAngle;
+ // shortest angular path so the lerp never spins the long way round
+ delta = ((delta + 180) % 360 + 360) % 360 - 180;
+ currentAngle += delta * 0.12;
+ }
+ // Only the two directional groups (trail + halo, and the aircraft)
+ // rotate — the core stays a perfectly round point of light on the
+ // path. Both are set to the same angle every frame so they always
+ // face the same way despite being separate DOM branches (see
+ // aircraftDirectionalRef for why they're split).
+ gsap.set(directional, { rotation: currentAngle });
+ gsap.set(aircraftDirectional, { rotation: currentAngle });
+
+ // Slide the illuminated window's trailing edge to l - trailLen and
+ // its leading edge to l (i.e. right under the pulse) by shifting
+ // the dash pattern's phase — the CSS blur on .wwg-illum-trail turns
+ // those two hard edges into a soft glow that fades into the resting
+ // route, rather than a sharp on/off cut.
+ illum.setAttribute("stroke-dashoffset", String(trailLen - l));
+
+ fractions.forEach((f, i) => {
+ if (!fired[i] && p >= f) {
+ fired[i] = true;
+ activateCard(i);
+ }
+ });
+ },
+ }, 0);
+
+ // Only once the pulse's own journey is complete does it fade out —
+ // the route itself is already at its resting opacity by this point
+ // (see above), so nothing further happens to it here.
+ tl.to(traveler, { opacity: 0, duration: 0.6, ease: "sine.out" }, FLIGHT_DURATION);
+ tl.to(illum, { opacity: 0, duration: 0.6, ease: "sine.out" }, FLIGHT_DURATION);
+ };
+
+ if (reduced) {
+ const { isMobile } = measure();
+ gsap.set(path, { opacity: isMobile ? 0.15 : 0.18 });
+ if (hasDrawSVG) {
+ gsap.set(path, { drawSVG: "100%" });
+ } else {
+ gsap.set(path, { strokeDasharray: "none", strokeDashoffset: 0 });
+ }
+ gsap.set(traveler, { opacity: 0 });
+ gsap.set(illum, { opacity: 0 });
+ } else {
+ gsap.set(path, { opacity: 0 });
+ if (hasDrawSVG) {
+ gsap.set(path, { drawSVG: "0%" });
+ }
+ gsap.set(traveler, { opacity: 0 });
+ gsap.set(illum, { opacity: 0 });
+
+ // Starts as soon as the section is mostly in view (matches the cards'
+ // own "top 85%" stagger-reveal trigger) rather than waiting for it to
+ // be ~centered — the ~6s draw+flight sequence needs that extra lead
+ // time to actually be seen during a normal scroll pass, not just
+ // triggered and then scrolled straight past.
+ st = ScrollTrigger.create({
+ trigger: wrap,
+ start: "top 85%",
+ once: true,
+ onEnter: runSequence,
+ });
+
+ rafIn = requestAnimationFrame(() => {
+ const r = wrap.getBoundingClientRect();
+ const vh = window.innerHeight || document.documentElement.clientHeight;
+ if (r.top < vh * 0.85 && r.bottom > 0) {
+ runSequence();
+ st?.kill();
+ }
+ });
+ }
+ }, wrap);
+
+ return () => {
+ ro?.disconnect();
+ cancelAnimationFrame(resizeRaf);
+ cancelAnimationFrame(initialRaf);
+ cancelAnimationFrame(rafIn);
+ if (loadHandler) window.removeEventListener("load", loadHandler);
+ st?.kill();
+ playedTimeline?.kill();
+ // Reverts/kills everything gsap.context tracked (ScrollTrigger.create,
+ // gsap.set calls, etc.) as a safety net beyond the explicit kills
+ // above, which exist specifically because the flight timeline is
+ // created asynchronously (inside onEnter) and so isn't guaranteed to
+ // be tracked by the context itself.
+ ctx.revert();
+ };
+ }, []);
+
return (
<>