fix scroll smooth

This commit is contained in:
2026-06-04 14:51:13 +05:30
parent 123092f4b8
commit b2d64bd335
15 changed files with 331 additions and 167 deletions

View File

@@ -3214,7 +3214,7 @@ body.rtl .e-con {
.btn-we-primary {
background: var(--we-white);
color: var(-we-primary);
color: var(--we-primary);
padding: 18px 48px;
border-radius: 100px;
font-weight: 700;

View File

@@ -14761,7 +14761,7 @@ a.e-con:hover p {
}
html.elementor-html {
background: url(../../../../../../../../../themes/logico/img/bg-transparency.png) center center repeat
background: url(/themes/logico/img/bg-transparency.png) center center repeat
}
:where(body).single-elementor-hf {

BIN
public/images/preloader.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -66,13 +66,34 @@ export default function SmoothScroll() {
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({
// /miletruth is one long stack of tall, pinned 3D sections, each with its own
// ScrollTrigger `scrub`. The default duration-based momentum (1.05s) compounds
// with that scrub, so the wheel feels heavy, slow and disconnected. On this
// route we switch to a snappy `lerp`-based follow + a higher wheel multiplier:
// the page travels fast and stays tightly locked to the wheel, while each
// section's scrub supplies the visual smoothing. Other routes keep the softer
// duration-based feel that suits their normal content.
const isMileTruth =
pathname === "/miletruth" || pathname.startsWith("/miletruth/");
const lenis = new Lenis(
isMileTruth
? {
lerp: 0.13, // snappy follow (higher = less smoothing lag)
wheelMultiplier: 1.3, // travel further per wheel tick → fast
touchMultiplier: 1.6,
orientation: "vertical",
gestureOrientation: "vertical",
smoothWheel: true,
}
: {
duration: 1.05,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
orientation: "vertical",
gestureOrientation: "vertical",
smoothWheel: true,
});
},
);
if (!window.location.hash) {
lenis.scrollTo(0, { immediate: true });

View File

@@ -796,32 +796,20 @@ body {
z-index: 2 !important;
}
/* Responsive constraints to keep all heroes matching the home page carousel perfectly */
@media (max-width: 1536px) {
/* Responsive constraints mirror the home page carousel (.elementor-element-6c7cbcb
.owl-carousel.owl-theme .content-item) EXACTLY so every page hero matches the
home hero at all sizes. The home card is a fixed 800px on all widths >= 841px
(large desktop, MacBook M1/Pro, and standard laptops alike) and only steps down
to 600px at <= 840px. Earlier this card shrank at <= 1536px, which is why the
About hero looked smaller than Home on MacBook M1/Pro (their ~1440-1512px logical
width falls below 1536px). Match the home breakpoint instead. */
@media (max-width: 840px) {
.custom-standard-hero-container {
padding: 10px 10px 10px 10px !important;
}
.custom-standard-hero-card {
height: 600px !important;
min-height: 600px !important;
}
}
@media (max-width: 1024px) {
.custom-standard-hero-container {
padding: 10px 10px 10px 10px !important;
}
.custom-standard-hero-card {
height: 620px !important;
min-height: 620px !important;
border-radius: 25px !important;
}
}
@media (max-width: 767px) {
.custom-standard-hero-container {
padding: 10px 10px 10px 10px !important;
}
.custom-standard-hero-card {
height: 560px !important;
min-height: 560px !important;
border-radius: 22px !important;
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @next/next/no-css-tags */
import type { Metadata } from "next";
import { Manrope, Space_Grotesk, Syne, DM_Sans, Inter } from "next/font/google";
import "./globals.css";

View File

@@ -83,7 +83,7 @@ export default function LoadingScreen() {
height={38}
priority
className="dm-loader__logo"
style={{ height: "auto" }}
style={{ width: "auto", height: "auto" }}
/>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
import dynamic from "next/dynamic";
import { motion, useMotionValue, useTransform, type MotionValue } from "framer-motion";
import gsap from "gsap";
@@ -24,6 +24,24 @@ function Counter({ mv }: { mv: MotionValue<number> }) {
return <span ref={ref}>{Math.round(mv.get())}</span>;
}
/** True only while a card's own opacity window is open (with a tiny buffer).
* Lets us keep future/past story cards out of the DOM — and off the compositor
* (each has `will-change`) — until their beat is actually on screen, so no
* workflow state is rendered before activation. Visually identical, since a
* card outside its window is opacity:0 anyway. */
function useInWindow(mv: MotionValue<number>, threshold = 0.01): boolean {
// `mv` is an external mutable store (a MotionValue). useTransform `.set()`s its
// output synchronously while the PARENT renders, so a plain `.on("change") -> setState`
// updates this component during the parent's render (React warns). useSyncExternalStore
// is built for exactly this: it reads a snapshot and reconciles store-changes-during-
// render safely. The snapshot is a primitive boolean, so it never re-renders needlessly.
return useSyncExternalStore(
(onStoreChange) => mv.on("change", onStoreChange),
() => mv.get() > threshold,
() => mv.get() > threshold,
);
}
/** Active step index from scroll progress (1 before the engine starts). */
function stepFromProgress(p: number): number {
let s = -1;
@@ -67,6 +85,8 @@ function StoryCard({
title: string;
children?: React.ReactNode;
}) {
// Don't mount this beat's card until its cross-fade window opens.
if (!useInWindow(opacity)) return null;
return (
<motion.div className="dm-lb-card-story" style={{ opacity, y }}>
<div className="dm-lb-card-story__head">
@@ -139,7 +159,9 @@ export default function LogisticsBrainSection({ connected = false }: { connected
trigger: el,
start: "top top",
end: "bottom bottom",
scrub: 0.5,
// Match Workflow 1's responsiveness (0.4) so the camera + overlay track the
// scroll with the same snappy feel — 0.5 made this section lag noticeably.
scrub: 0.4,
invalidateOnRefresh: true,
onUpdate: (self) => {
const p = self.progress;
@@ -151,7 +173,7 @@ export default function LogisticsBrainSection({ connected = false }: { connected
if (nstep !== lastStep) { lastStep = nstep; setStep(nstep); }
},
});
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
const refresh = setTimeout(() => ScrollTrigger.refresh(), 120);
return () => { clearTimeout(refresh); st.kill(); };
}, [scroll]);
@@ -185,9 +207,14 @@ export default function LogisticsBrainSection({ connected = false }: { connected
<div className="dm-lb-card">
{mountScene && (
<div className="dm-lb-canvas">
<LogisticsBrainCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} />
<LogisticsBrainCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive && pinState === "pinned"} />
</div>
)}
{/* Overlay is mounted only once the section is pinned/activated, so its
content (intro hint, header, story cards) can never be seen during the
approach ("before"), where the sticky sits at the top of the tall
section just off the previous workflow's seam. */}
{pinState !== "before" && (
<div className="dm-lb-ui">
{/* Persistent header: what this is + where we are in the workflow */}
<motion.div className="dm-lb-top" style={{ opacity: headerOpacity }}>
@@ -281,6 +308,7 @@ export default function LogisticsBrainSection({ connected = false }: { connected
</motion.div>
</motion.div>
</div>
)}
</div>
</div>
<style>{styles}</style>
@@ -289,8 +317,13 @@ export default function LogisticsBrainSection({ connected = false }: { connected
}
const styles = `
.dm-lb { position: relative; height: 640vh; background: transparent; }
.dm-lb-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden; }
/* Scroll length tuned for pacing: ~77vh per engine step (was 107vh) so the 6
beats complete in noticeably less scrolling — closer to Workflow 1's cadence
and with far less perceived empty space between workflows. Beat windows are
progress-based (0…1), so they stay correctly aligned at any height. */
.dm-lb { position: relative; height: 460vh; background: transparent; }
.dm-lb-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden;
will-change: transform; transform: translateZ(0); backface-visibility: hidden; }
.dm-lb.is-pinned .dm-lb-sticky { position: fixed; top: 0; left: 0; }
.dm-lb.is-after .dm-lb-sticky { position: absolute; top: auto; bottom: 0; }
@@ -310,6 +343,9 @@ const styles = `
.dm-lb.is-connected .dm-lb-card {
top: 20px !important; left: 20px !important; right: 20px !important; bottom: 0 !important;
border-radius: 28px 28px 0 0 !important; border-bottom: none !important;
/* Flush against the Innovation card below — drop the heavy downward shadow so it
doesn't cast a dark band onto that card's top edge (the two read as one container). */
box-shadow: none !important;
}
@media (max-width: 767px) {
.dm-lb.is-connected .dm-lb-card {
@@ -331,23 +367,23 @@ const styles = `
.dm-lb-top { position: absolute; top: clamp(96px, 13vh, 128px); left: 0; right: 0; z-index: 5;
display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 0 16px; overflow: visible; }
.dm-lb-eyebrow {
display: inline-flex; align-items: center; gap: 8px; font-size: 11px; line-height: 1.35; letter-spacing: 0.28em; text-transform: uppercase;
color: #F2667A; padding: 9px 18px; border-radius: 999px; background: rgba(192,18,39,0.10);
border: 1px solid rgba(226,53,66,0.32); backdrop-filter: blur(8px); white-space: nowrap; overflow: visible; }
display: inline-flex; align-items: center; gap: 8px; font-size: 13px; line-height: 1.35; letter-spacing: 0.18em; font-weight: 700; text-transform: uppercase;
color: #ffffff; padding: 9px 20px; border-radius: 999px; background: rgba(192,18,39,0.16);
border: 1px solid rgba(226,53,66,0.45); backdrop-filter: blur(8px); white-space: nowrap; overflow: visible; }
.dm-lb-dot { width: 6px; height: 6px; border-radius: 50%; background: #E2354A; box-shadow: 0 0 10px #E2354A; }
.dm-lb-rail { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; max-width: 940px; }
.dm-lb-rail__step { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px; border-radius: 999px;
.dm-lb-rail { display: flex; align-items: center; justify-content: center; flex-wrap: nowrap; max-width: min(1160px, 96vw); }
.dm-lb-rail__step { display: inline-flex; align-items: center; gap: 8px; padding: 6px 13px; border-radius: 999px; flex-shrink: 0;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
backdrop-filter: blur(6px); transition: all 0.45s cubic-bezier(0.22,1,0.36,1); }
.dm-lb-rail__num { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 800; color: rgba(234,242,255,0.6); background: rgba(255,255,255,0.08); }
.dm-lb-rail__title { font-size: 11px; font-weight: 600; letter-spacing: 0.03em; color: rgba(234,242,255,0.55); white-space: nowrap; }
.dm-lb-rail__num { width: 20px; height: 20px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 800; color: rgba(255,255,255,0.9); background: rgba(255,255,255,0.12); }
.dm-lb-rail__title { font-size: clamp(12.5px, 1.05vw, 14px); font-weight: 700; letter-spacing: 0.04em; color: rgba(255,255,255,0.95); white-space: nowrap; }
.dm-lb-rail__step.is-current { background: rgba(192,18,39,0.18); border-color: rgba(226,53,66,0.55); box-shadow: 0 0 22px -6px rgba(226,53,66,0.7); }
.dm-lb-rail__step.is-current .dm-lb-rail__num { background: linear-gradient(135deg,#E2354A,#C01227); color: #fff; }
.dm-lb-rail__step.is-current .dm-lb-rail__title { color: #fff; }
.dm-lb-rail__step.is-done .dm-lb-rail__num { background: #22C55E; color: #04130a; }
.dm-lb-rail__step.is-done .dm-lb-rail__title { color: rgba(234,242,255,0.78); }
.dm-lb-rail__step.is-done .dm-lb-rail__title { color: rgba(255,255,255,0.92); }
.dm-lb-rail__line { width: 14px; height: 1px; background: rgba(255,255,255,0.12); margin: 0 3px; transition: background 0.45s ease; }
.dm-lb-rail__line.is-on { background: linear-gradient(90deg,#22C55E,#E2354A); }
@@ -439,7 +475,7 @@ const styles = `
.dm-lb-rail__line { width: 9px; }
}
@media (max-width: 767px) {
.dm-lb { height: 540vh; }
.dm-lb { height: 400vh; }
.dm-lb-kpis { gap: 12px; }
.dm-lb-kpi { min-width: 96px; padding: 14px 14px; }
.dm-lb-card-story { left: 0; right: 0; margin: 0 auto; width: calc(100% - 28px); bottom: clamp(20px, 5vh, 44px); padding: 14px 16px; }

View File

@@ -36,6 +36,79 @@ const WORKFLOW_STEPS = [
{ label: "Monitor", icon: "📊", activateAt: 5 },
];
/**
* Bottom "Live Analytics" ticker. Self-contained: it owns its five fluctuation
* timers and the state they mutate, so a tick re-renders only this ~10-node
* subtree instead of the entire (canvas + overlay) OptimizationSection — which
* previously re-reconciled on every timer fire, competing with scroll updates.
*/
const LiveInsightBar = React.memo(function LiveInsightBar() {
const [orders, setOrders] = useState(59);
const [accuracy, setAccuracy] = useState(98.7);
const [activeVehicles, setActiveVehicles] = useState(5);
const [carbon, setCarbon] = useState(-12.0);
const [routeHealth, setRouteHealth] = useState(99.4);
useEffect(() => {
// Pause every ticker while the tab is hidden — no point re-rendering offscreen.
const guard = (fn: () => void) => () => {
if (!document.hidden) fn();
};
const ordersInterval = setInterval(guard(() => {
setOrders((prev) => prev + (Math.random() > 0.4 ? 1 : 0));
}), 4500);
const accuracyInterval = setInterval(guard(() => {
setAccuracy((prev) => {
const next = prev + (Math.random() - 0.5) * 0.15;
return parseFloat(Math.min(99.1, Math.max(98.4, next)).toFixed(2));
});
}), 2800);
const vehicleInterval = setInterval(guard(() => {
setActiveVehicles((prev) => (prev === 5 ? (Math.random() > 0.5 ? 4 : 5) : (Math.random() > 0.3 ? 5 : 4)));
}), 3500);
const carbonInterval = setInterval(guard(() => {
setCarbon((prev) => {
const next = prev + (Math.random() - 0.5) * 0.2;
return parseFloat(Math.min(-11.5, Math.max(-12.8, next)).toFixed(1));
});
}), 3200);
const healthInterval = setInterval(guard(() => {
setRouteHealth((prev) => {
const next = prev + (Math.random() - 0.5) * 0.12;
return parseFloat(Math.min(99.9, Math.max(98.8, next)).toFixed(2));
});
}), 2500);
return () => {
clearInterval(ordersInterval);
clearInterval(accuracyInterval);
clearInterval(vehicleInterval);
clearInterval(carbonInterval);
clearInterval(healthInterval);
};
}, []);
return (
<motion.div
className="dm-opt-insight"
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
>
<span className="dm-opt-insight__dot" />
<span className="dm-opt-insight__text">Live Analytics: <strong>{orders} Orders</strong></span>
<span className="dm-opt-insight__sep" />
<span className="dm-opt-insight__text">AI Accuracy: <strong>{accuracy}%</strong></span>
<span className="dm-opt-insight__sep" />
<span className="dm-opt-insight__text">Fleet: <strong>{activeVehicles}/5 EV Active</strong></span>
<span className="dm-opt-insight__sep" />
<span className="dm-opt-insight__text">Route Health: <strong>{routeHealth}%</strong></span>
<span className="dm-opt-insight__sep" />
<span className="dm-opt-insight__text">Carbon: <strong>{carbon}%</strong></span>
</motion.div>
);
});
export default function OptimizationSection() {
const containerRef = useRef<HTMLDivElement>(null);
const progressRef = useRef(0);
@@ -48,55 +121,6 @@ export default function OptimizationSection() {
const [isMobile, setIsMobile] = useState(false);
const [reduced, setReduced] = useState(false);
const [orders, setOrders] = useState(59);
const [accuracy, setAccuracy] = useState(98.7);
const [activeVehicles, setActiveVehicles] = useState(5);
const [carbon, setCarbon] = useState(-12.0);
const [routeHealth, setRouteHealth] = useState(99.4);
// Interval timers for high-fidelity live dashboard fluctuations
useEffect(() => {
const ordersInterval = setInterval(() => {
setOrders((prev) => prev + (Math.random() > 0.4 ? 1 : 0));
}, 4500);
const accuracyInterval = setInterval(() => {
setAccuracy((prev) => {
const delta = (Math.random() - 0.5) * 0.15;
const next = prev + delta;
return parseFloat(Math.min(99.1, Math.max(98.4, next)).toFixed(2));
});
}, 2800);
const vehicleInterval = setInterval(() => {
setActiveVehicles((prev) => (prev === 5 ? (Math.random() > 0.5 ? 4 : 5) : (Math.random() > 0.3 ? 5 : 4)));
}, 3500);
const carbonInterval = setInterval(() => {
setCarbon((prev) => {
const delta = (Math.random() - 0.5) * 0.2;
const next = prev + delta;
return parseFloat(Math.min(-11.5, Math.max(-12.8, next)).toFixed(1));
});
}, 3200);
const healthInterval = setInterval(() => {
setRouteHealth((prev) => {
const delta = (Math.random() - 0.5) * 0.12;
const next = prev + delta;
return parseFloat(Math.min(99.9, Math.max(98.8, next)).toFixed(2));
});
}, 2500);
return () => {
clearInterval(ordersInterval);
clearInterval(accuracyInterval);
clearInterval(vehicleInterval);
clearInterval(carbonInterval);
clearInterval(healthInterval);
};
}, []);
// Environment detection (client only).
useEffect(() => {
const mqMobile = window.matchMedia("(max-width: 767px)");
@@ -184,7 +208,7 @@ export default function OptimizationSection() {
},
});
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
const refresh = setTimeout(() => ScrollTrigger.refresh(), 120);
return () => {
clearTimeout(refresh);
st.kill();
@@ -222,7 +246,11 @@ export default function OptimizationSection() {
progress={progressRef}
reduced={reduced}
isMobile={isMobile}
active={sceneActive}
// Only run the render loop while the section is actually pinned
// (filling the viewport). At a workflow seam two sections can both
// satisfy their activeIo margin; without the pin gate their two
// full-screen Bloom passes would run at once and drop FPS.
active={sceneActive && pinState === "pinned"}
/>
</div>
)}
@@ -382,25 +410,9 @@ export default function OptimizationSection() {
{/* KPI metrics */}
<div className="dm-opt-foot">
<MetricsPanel scroll={scroll} />
{/* Bottom insight bar */}
<motion.div
className="dm-opt-insight"
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
>
<span className="dm-opt-insight__dot" />
<span className="dm-opt-insight__text">Live Analytics: <strong>{orders} Orders</strong></span>
<span className="dm-opt-insight__sep" />
<span className="dm-opt-insight__text">AI Accuracy: <strong>{accuracy}%</strong></span>
<span className="dm-opt-insight__sep" />
<span className="dm-opt-insight__text">Fleet: <strong>{activeVehicles}/5 EV Active</strong></span>
<span className="dm-opt-insight__sep" />
<span className="dm-opt-insight__text">Route Health: <strong>{routeHealth}%</strong></span>
<span className="dm-opt-insight__sep" />
<span className="dm-opt-insight__text">Carbon: <strong>{carbon}%</strong></span>
</motion.div>
{/* Bottom insight bar — isolated so its live tickers don't re-render
the whole section (and never fight the scroll handler). */}
<LiveInsightBar />
</div>
</div>
</div>
@@ -427,6 +439,11 @@ const styles = `
height: 100vh;
overflow: hidden;
background: transparent;
/* Promote the pinned layer to its own GPU compositing layer so scroll-driven
pin/scrub updates never trigger main-thread paints of the rest of the page. */
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.dm-opt.is-pinned .dm-opt-sticky { position: fixed; top: 0; left: 0; }
.dm-opt.is-after .dm-opt-sticky { position: absolute; top: auto; bottom: 0; }

View File

@@ -60,7 +60,9 @@ export default function MileTruthHero() {
background-size: cover !important;
background-position: center !important;
background-repeat: no-repeat !important;
min-height: 773px;
/* Match the home page hero card (800px) so MileTruth has the same visual
presence on large desktop, MacBook M1/Pro, and standard laptops. */
min-height: 800px;
display: flex;
flex-direction: column;
justify-content: center;

View File

@@ -1,11 +1,12 @@
"use client";
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import OptimizationSection from "../optimization/OptimizationSection";
export default function Workflow1() {
const [activeSlide, setActiveSlide] = useState(0);
const [paused, setPaused] = useState(false);
const slides = [
{
@@ -22,6 +23,16 @@ export default function Workflow1() {
}
];
// Auto-advance the carousel every 5s, infinite loop. Keyed on activeSlide so any
// manual selection resets the timer; pauses while the user hovers the card.
useEffect(() => {
if (paused) return;
const id = setTimeout(() => {
setActiveSlide((prev) => (prev + 1) % slides.length);
}, 5000);
return () => clearTimeout(id);
}, [activeSlide, paused, slides.length]);
return (
<section className="dm-wf1" aria-label="Workflow 1 — Impact of Optimisation & Performance">
@@ -30,7 +41,7 @@ export default function Workflow1() {
{/* ── Bottom sub-section: Performance content, flush + colour-matched to the
optimisation section above so the whole workflow reads as one container ── */}
<div className="dm-wf1-card">
<div className="dm-wf1-card" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
{/* Left Column: Overlapping Chevron Graphic */}
<div className="dm-workflow-left">
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
@@ -105,6 +116,12 @@ const styles = `
margin: 0 auto 0;
}
/* Cancel the global "section { padding: 6rem 0 }" (custom-frontend.min.css): both
this wrapper and the nested .dm-opt are sections, so that 96px top+bottom stacked
into large empty bands above / between the workflows. These are full-bleed pinned
experiences whose cards butt together via their own insets — no section padding. */
.dm-wf1, .dm-wf1 .dm-opt { padding-top: 0; padding-bottom: 0; }
/* Performance card — aligned to the optimisation card (20px side insets),
navy-matched, flat top, rounded bottom, pulled up to close the seam. */
.dm-wf1-card {
@@ -115,8 +132,10 @@ const styles = `
border: 1px solid rgba(255, 255, 255, 0.05);
border-top: none;
border-radius: 0 0 42px 42px;
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5);
padding: 48px 60px;
/* No shadow: this card is flush under the optimisation card and merges with it as one
continuous container — a shadow here would re-introduce a dark band at the seam. */
box-shadow: none;
padding: 36px 60px;
display: flex;
align-items: center;
justify-content: space-between;

View File

@@ -1,11 +1,12 @@
"use client";
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import LogisticsBrainSection from "../logisticsbrain/LogisticsBrainSection";
export default function Workflow2() {
const [activeSlide, setActiveSlide] = useState(0);
const [paused, setPaused] = useState(false);
const slides = [
{
@@ -22,6 +23,16 @@ export default function Workflow2() {
}
];
// Auto-advance the carousel every 5s, infinite loop. Keyed on activeSlide so any
// manual selection resets the timer; pauses while the user hovers the card.
useEffect(() => {
if (paused) return;
const id = setTimeout(() => {
setActiveSlide((prev) => (prev + 1) % slides.length);
}, 5000);
return () => clearTimeout(id);
}, [activeSlide, paused, slides.length]);
return (
<section className="dm-wf2" aria-label="Workflow 2 — How Our Logistics Brain Works & Innovation">
@@ -30,7 +41,7 @@ export default function Workflow2() {
{/* ── Bottom sub-section: Innovation content, flush + colour-matched to the
logistics-brain card above so the whole workflow reads as one container ── */}
<div className="dm-wf2-card">
<div className="dm-wf2-card" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
{/* Left Column: Overlapping Chevron Graphic */}
<div className="dm-workflow-left">
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
@@ -107,6 +118,12 @@ const styles = `
margin: 0 auto 0;
}
/* Cancel the global "section { padding: 6rem 0 }" (custom-frontend.min.css): both
this wrapper and the nested .dm-lb are sections, so that 96px top+bottom stacked
into large empty bands above / between the workflows. These are full-bleed pinned
experiences whose cards butt together via their own insets — no section padding. */
.dm-wf2, .dm-wf2 .dm-lb { padding-top: 0; padding-bottom: 0; }
/* Innovation card — aligned to the logistics-brain card (20px side insets),
red/black-matched, flat top, rounded bottom, pulled up to close the seam. */
.dm-wf2-card {
@@ -117,8 +134,10 @@ const styles = `
border: 1px solid rgba(192, 18, 39, 0.16);
border-top: none;
border-radius: 0 0 28px 28px;
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5);
padding: 48px 60px;
/* No shadow: this card is flush under the logistics-brain card and merges with it as one
continuous container — a shadow here would re-introduce a dark band at the seam. */
box-shadow: none;
padding: 36px 60px;
display: flex;
align-items: center;
justify-content: space-between;

View File

@@ -1,11 +1,12 @@
"use client";
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import StrategySection from "../strategy/StrategySection";
export default function Workflow3() {
const [activeSlide, setActiveSlide] = useState(0);
const [paused, setPaused] = useState(false);
const slides = [
{
@@ -22,6 +23,16 @@ export default function Workflow3() {
}
];
// Auto-advance the carousel every 5s, infinite loop. Keyed on activeSlide so any
// manual selection resets the timer; pauses while the user hovers the card.
useEffect(() => {
if (paused) return;
const id = setTimeout(() => {
setActiveSlide((prev) => (prev + 1) % slides.length);
}, 5000);
return () => clearTimeout(id);
}, [activeSlide, paused, slides.length]);
return (
<section className="dm-wf3" aria-label="Workflow 3 — Happier Riders. Higher Fulfillment. & Strategy">
@@ -32,7 +43,7 @@ export default function Workflow3() {
{/* ── Bottom sub-section: Strategy content, flush + pulled up to butt against
the 3D card's flat bottom so the whole workflow reads as one container —
the same connected structure used in Workflow 1 & 2 ── */}
<div className="dm-wf3-card">
<div className="dm-wf3-card" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
{/* Left Column: Overlapping Chevron Graphic */}
<div className="dm-workflow-left">
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
@@ -107,6 +118,12 @@ const styles = `
margin: 0 auto 0;
}
/* Cancel the global "section { padding: 6rem 0 }" (custom-frontend.min.css): both
this wrapper and the nested .dm-st are sections, so that 96px top+bottom stacked
into large empty bands above / between the workflows. These are full-bleed pinned
experiences whose cards butt together via their own insets — no section padding. */
.dm-wf3, .dm-wf3 .dm-st { padding-top: 0; padding-bottom: 0; }
.dm-wf3-card {
position: relative;
z-index: 2;
@@ -115,8 +132,10 @@ const styles = `
border: 1px solid rgba(255, 255, 255, 0.06);
border-top: none;
border-radius: 0 0 28px 28px;
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5);
padding: 48px 60px;
/* No shadow: this card is flush under the strategy 3D card and merges with it as one
continuous container — a shadow here would re-introduce a dark band at the seam. */
box-shadow: none;
padding: 36px 60px;
display: flex;
align-items: center;
justify-content: space-between;

View File

@@ -123,7 +123,10 @@ function useLabelFade(i: number, progress: React.RefObject<number>, awake: boole
useFrame(() => {
if (!awake) return;
const idx = (progress.current ?? 0) * (N - 1);
const op = THREE.MathUtils.clamp(1 - (Math.abs(idx - i) - 0.4) / 0.45, 0, 1);
// Full within ±0.2 of the district centre, fading to 0 by ±0.5 — i.e. exactly
// at the stage boundary where `focused` flips and the labels unmount, so the
// mount/unmount is never visible (labels are already at 0 opacity by then).
const op = THREE.MathUtils.clamp(1 - (Math.abs(idx - i) - 0.2) / 0.3, 0, 1);
for (const el of labels.current) el.style.opacity = String(op);
});
return register;
@@ -321,7 +324,7 @@ function OrderPacket({ curve, offset }: { curve: THREE.Curve<THREE.Vector3>; off
}
/** A vehicle parked on its own glowing route node (flat on the floor, no white pad). */
function RiderAvatar({ rider, register, awake }: { rider: typeof RIDERS[number]; register: (el: HTMLElement | null) => void; awake: boolean }) {
function RiderAvatar({ rider, register, awake, focused }: { rider: typeof RIDERS[number]; register: (el: HTMLElement | null) => void; awake: boolean; focused: boolean }) {
return (
<group position={[rider.x, 0, ROW_Z]}>
<mesh position={[0, 0.014, 0]} rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[0.52, 0.64, 36]} /><meshBasicMaterial color={GREEN} transparent opacity={0.6} side={THREE.DoubleSide} toneMapped={false} /></mesh>
@@ -331,7 +334,7 @@ function RiderAvatar({ rider, register, awake }: { rider: typeof RIDERS[number];
<GLBVehicle kind={rider.kind} awake={awake} />
</Suspense>
</group>
{awake && (
{focused && (
<Html center distanceFactor={9} position={[0, 1.5, 0]} zIndexRange={[20, 0]} pointerEvents="none">
<div className="dm-st3d-chip" ref={register}>
<span className="dm-st3d-chip__ico">{rider.icon}</span>
@@ -346,7 +349,7 @@ function RiderAvatar({ rider, register, awake }: { rider: typeof RIDERS[number];
const ORDERS_SRC: [number, number, number] = [3.3, 0.45, -0.7];
const HUB_CORE: [number, number, number] = [0, 0.55, -1.4];
const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced, awake, focused }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean; focused: boolean }) {
const register = useLabelFade(i, progress, awake);
const counter = useRef<HTMLSpanElement>(null);
const halo = useRef<THREE.Mesh>(null);
@@ -389,9 +392,9 @@ const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced, awake }:
{/* soft contact shadow grounding the fleet (baked once for performance) */}
<ContactShadows position={[0, 0.02, ROW_Z]} scale={[7.5, 2.6]} resolution={512} blur={2.6} far={1.2} opacity={0.4} frames={1} color="#1e293b" />
{RIDERS.map((r) => <RiderAvatar key={r.id} rider={r} register={register} awake={awake} />)}
{RIDERS.map((r) => <RiderAvatar key={r.id} rider={r} register={register} awake={awake} focused={focused} />)}
{awake && (
{focused && (
<>
<Html center distanceFactor={9} position={[ORDERS_SRC[0], ORDERS_SRC[1] + 0.5, ORDERS_SRC[2]]} zIndexRange={[20, 0]} pointerEvents="none">
<div className="dm-st3d-tag" style={{ ["--tc" as string]: GREEN }} ref={register}>📄 orders.csv</div>
@@ -422,7 +425,7 @@ const STRATS = [
];
const CORE: [number, number, number] = [0, 1.45, -2.7];
const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduced, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduced, awake, focused }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean; focused: boolean }) {
const register = useLabelFade(i, progress, awake);
const ring = useRef<THREE.Mesh>(null);
const coreMesh = useRef<THREE.Mesh>(null);
@@ -450,7 +453,7 @@ const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduc
<mesh position={[0, 0.65, -2.7]}><cylinderGeometry args={[0.4, 0.62, 1.2, 24]} /><meshStandardMaterial color="#2a3350" metalness={0.5} roughness={0.4} /></mesh>
<mesh ref={coreMesh} position={CORE}><icosahedronGeometry args={[0.6, 1]} /><meshStandardMaterial color={PURPLE} emissive={PURPLE} emissiveIntensity={1.3} toneMapped={false} flatShading /></mesh>
<mesh ref={ring} position={CORE} rotation={[Math.PI / 2.4, 0, 0]}><torusGeometry args={[0.98, 0.03, 10, 44]} /><meshStandardMaterial color={PURPLE} emissive={PURPLE} emissiveIntensity={1.1} toneMapped={false} /></mesh>
{awake && (
{focused && (
<Html center distanceFactor={9} position={[0, 2.5, -2.7]} zIndexRange={[20, 0]} pointerEvents="none">
<div className="dm-st3d-tag" style={{ ["--tc" as string]: PURPLE }} ref={register}>🤖 AI Engine</div>
</Html>
@@ -465,7 +468,7 @@ const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduc
{/* flat glowing route node (consistent with the dispatch network) */}
<mesh position={[l.end[0], 0.135, l.end[2]]} rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[0.34, 0.44, 28]} /><meshBasicMaterial color={col} transparent opacity={l.unified ? 0.6 : 0.4} side={THREE.DoubleSide} toneMapped={false} /></mesh>
<GlowNode position={l.end} color={col} size={l.unified ? 0.15 : 0.11} />
{awake && (
{focused && (
<Html center distanceFactor={9} position={[l.end[0], l.end[1] + 0.62, l.end[2]]} zIndexRange={[20, 0]} pointerEvents="none">
<div className={`dm-st3d-tag ${l.unified ? "is-u" : "is-muted"}`} style={{ ["--tc" as string]: l.unified ? PURPLE : "#94a3b8" }} ref={register}>{l.name}</div>
</Html>
@@ -532,7 +535,7 @@ function DeliveryBadge({ pos, i, progress }: { pos: [number, number, number]; i:
);
}
const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced, awake, focused }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean; focused: boolean }) {
const register = useLabelFade(i, progress, awake);
// OPEN delivery path (Dispatch Hub → … → Delivery). Static "road"; only the truck moves.
const route = useMemo(() => {
@@ -571,7 +574,7 @@ const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced, aw
{/* delivery arrival pulse (only mounted/animated while this district is active) */}
{awake && <DeliveryPulse pos={[delivery[0], 0.02, delivery[1]]} i={i} progress={progress} />}
{awake && (
{focused && (
<>
<Html center distanceFactor={9} position={[DEPOT3[0], 1.05, DEPOT3[1]]} zIndexRange={[20, 0]} pointerEvents="none">
<div className="dm-st3d-tag" style={{ ["--tc" as string]: BLUE }} ref={register}>🏢 Dispatch Hub</div>
@@ -610,7 +613,7 @@ const KPIS = [
{ n: "Battery", v: 100, a: 0.66 },
];
const CommandCenter = React.memo(function CommandCenter({ i, progress, awake }: { i: number; progress: React.RefObject<number>; awake: boolean }) {
const CommandCenter = React.memo(function CommandCenter({ i, progress, awake, focused }: { i: number; progress: React.RefObject<number>; awake: boolean; focused: boolean }) {
const register = useLabelFade(i, progress, awake);
const screens = useMemo(
() =>
@@ -631,7 +634,7 @@ const CommandCenter = React.memo(function CommandCenter({ i, progress, awake }:
<RoundedBox args={[1.7, 1.15, 0.06]} radius={0.06} smoothness={2}>
<meshStandardMaterial color="#ffffff" emissive={ORANGE} emissiveIntensity={0.18} metalness={0.1} roughness={0.4} transparent opacity={0.92} />
</RoundedBox>
{awake && (
{focused && (
<Html center distanceFactor={8} position={[0, 0, 0.05]} zIndexRange={[20, 0]} pointerEvents="none">
<div className="dm-st3d-kpi" style={{ ["--tc" as string]: ORANGE }} ref={register}>
<span className="dm-st3d-kpi__n">{s.n}</span>
@@ -643,7 +646,7 @@ const CommandCenter = React.memo(function CommandCenter({ i, progress, awake }:
</group>
))}
{awake && (
{focused && (
<Html center distanceFactor={9} position={[0, 3.1, -0.4]} zIndexRange={[20, 0]} pointerEvents="none">
<div className="dm-st3d-score" style={{ ["--tc" as string]: ORANGE }} ref={register}>Performance Grade <b>A</b> · 4.5 / 5</div>
</Html>
@@ -663,7 +666,7 @@ const PODIUM = [
{ n: "Proximity", v: 64, x: 2.4, win: false },
];
function Pillar({ p, register, awake }: { p: typeof PODIUM[number]; register: (el: HTMLElement | null) => void; awake: boolean }) {
function Pillar({ p, register, focused }: { p: typeof PODIUM[number]; register: (el: HTMLElement | null) => void; focused: boolean }) {
const h = 0.6 + (p.v / 100) * 2.2;
const col = p.win ? RED : "#94a3b8";
return (
@@ -672,7 +675,7 @@ function Pillar({ p, register, awake }: { p: typeof PODIUM[number]; register: (e
<boxGeometry args={[1.0, h, 1.0]} />
<meshStandardMaterial color={col} emissive={col} emissiveIntensity={p.win ? 0.7 : 0.15} metalness={0.2} roughness={0.4} transparent opacity={p.win ? 0.96 : 0.7} toneMapped={false} />
</mesh>
{awake && (
{focused && (
<Html center distanceFactor={9} position={[0, h + 0.45, 0]} zIndexRange={[20, 0]} pointerEvents="none">
<div className={`dm-st3d-tag ${p.win ? "is-win" : "is-muted"}`} style={{ ["--tc" as string]: col }} ref={register}><b>{p.v}%</b>&nbsp;{p.n}</div>
</Html>
@@ -681,7 +684,7 @@ function Pillar({ p, register, awake }: { p: typeof PODIUM[number]; register: (e
);
}
const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, isMobile, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; isMobile: boolean; awake: boolean }) {
const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, isMobile, awake, focused }: { i: number; progress: React.RefObject<number>; reduced: boolean; isMobile: boolean; awake: boolean; focused: boolean }) {
const register = useLabelFade(i, progress, awake);
const trophy = useRef<THREE.Group>(null);
useFrame((state) => {
@@ -697,7 +700,7 @@ const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, is
<mesh position={[0, 0.12, -0.6]} rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[4.1, 4.32, 56]} /><meshBasicMaterial color={RED} transparent opacity={0.35} side={THREE.DoubleSide} toneMapped={false} /></mesh>
<mesh position={[-2.4, 0.14, -0.6]}><cylinderGeometry args={[0.85, 0.9, 0.14, 28]} /><meshStandardMaterial color={RED} emissive={RED} emissiveIntensity={0.9} toneMapped={false} /></mesh>
{PODIUM.map((p) => <Pillar key={p.n} p={p} register={register} awake={awake} />)}
{PODIUM.map((p) => <Pillar key={p.n} p={p} register={register} focused={focused} />)}
<mesh position={[-2.4, 3.0, -0.6]}><coneGeometry args={[1.0, 2.6, 28, 1, true]} /><meshBasicMaterial color={RED} transparent opacity={0.07} side={THREE.DoubleSide} toneMapped={false} /></mesh>
<group ref={trophy} position={[-2.4, 3.5, -0.6]}>
@@ -706,9 +709,9 @@ const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, is
<mesh position={[0, -0.22, 0]}><cylinderGeometry args={[0.18, 0.22, 0.1, 16]} /><meshStandardMaterial color="#FFD45A" emissive="#FFB020" emissiveIntensity={1} toneMapped={false} /></mesh>
</group>
{awake && !reduced && <Sparkles count={isMobile ? 16 : 28} scale={[5, 4, 4]} position={[-2.4, 2.6, -0.6]} size={3.2} speed={0.5} opacity={0.9} color="#ff9aa9" />}
{awake && !reduced && <Sparkles count={isMobile ? 12 : 16} scale={[5, 4, 4]} position={[-2.4, 2.6, -0.6]} size={3.2} speed={0.5} opacity={0.9} color="#ff9aa9" />}
{awake && (
{focused && (
<Html center distanceFactor={9} position={[0.7, 3.0, -0.6]} zIndexRange={[20, 0]} pointerEvents="none">
<div className="dm-st3d-winner3d" ref={register}>
<span className="dm-st3d-winner3d__top">🏆 Best Strategy</span>
@@ -818,14 +821,18 @@ function Scene({ progress, reduced, isMobile, stage, active, perf }: { progress:
<Floor />
<DataRoad />
<IntakeHub i={0} progress={progress} reduced={reduced} awake={near(0)} />
<StrategyNetwork i={1} progress={progress} reduced={reduced} awake={near(1)} />
<CityRouteMap i={2} progress={progress} reduced={reduced} awake={near(2)} />
<CommandCenter i={3} progress={progress} awake={near(3)} />
<WinnerPodium i={4} progress={progress} reduced={reduced} isMobile={isMobile} awake={near(4)} />
{/* `awake` (±1) keeps geometry/shaders/animation warm for a pop-free
transition; `focused` (current district only) gates the expensive
in-canvas <Html> labels so only ~one district's worth project + write
to the DOM each frame instead of three. */}
<IntakeHub i={0} progress={progress} reduced={reduced} awake={near(0)} focused={stage === 0} />
<StrategyNetwork i={1} progress={progress} reduced={reduced} awake={near(1)} focused={stage === 1} />
<CityRouteMap i={2} progress={progress} reduced={reduced} awake={near(2)} focused={stage === 2} />
<CommandCenter i={3} progress={progress} awake={near(3)} focused={stage === 3} />
<WinnerPodium i={4} progress={progress} reduced={reduced} isMobile={isMobile} awake={near(4)} focused={stage === 4} />
{heavyFx && (
<Sparkles count={30} scale={[18, 7, N * 13]} position={[0, 3, (-(N - 1) * 13) / 2]} size={2} speed={0.22} opacity={0.35} color="#9aa6c4" />
<Sparkles count={16} scale={[18, 7, N * 13]} position={[0, 3, (-(N - 1) * 13) / 2]} size={2} speed={0.22} opacity={0.35} color="#9aa6c4" />
)}
{heavyFx && (

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
import dynamic from "next/dynamic";
import { motion, useMotionValue, useTransform, type MotionValue } from "framer-motion";
import gsap from "gsap";
@@ -12,6 +12,23 @@ const StrategyCanvas = dynamic(() => import("./StrategyCanvas"), { ssr: false })
/** Center of each stage's scroll window (0…1). */
const CENTER = (i: number) => i / (N - 1);
/** True only while a card's own opacity window is open (tiny buffer). Keeps
* not-yet-reached stage cards out of the DOM / off the compositor until their
* stage is on screen — no future workflow state is rendered before activation.
* Visually identical: a card outside its window is opacity:0 regardless. */
function useInWindow(mv: MotionValue<number>, threshold = 0.01): boolean {
// `mv` is an external mutable store (a MotionValue). useTransform `.set()`s its
// output synchronously while the PARENT renders, so a plain `.on("change") -> setState`
// updates this component during the parent's render (React warns). useSyncExternalStore
// is built for exactly this: it reads a snapshot and reconciles store-changes-during-
// render safely. The snapshot is a primitive boolean, so it never re-renders needlessly.
return useSyncExternalStore(
(onStoreChange) => mv.on("change", onStoreChange),
() => mv.get() > threshold,
() => mv.get() > threshold,
);
}
/** Persistent top rail: the 5 stages, current one highlighted. */
function StageRail({ active }: { active: number }) {
return (
@@ -45,9 +62,13 @@ function StageCard({
children: React.ReactNode;
}) {
const c = CENTER(i);
const opacity = useTransform(scroll, [c - 0.14, c - 0.06, c + 0.06, c + 0.14], [0, 1, 1, 0]);
const y = useTransform(scroll, [c - 0.14, c - 0.05], [34, 0]);
// Window kept < the 0.25 per-stage span (5 stages) so two adjacent stage cards
// never overlap — one stage's card is fully out before the next fades in.
const opacity = useTransform(scroll, [c - 0.1, c - 0.05, c + 0.05, c + 0.1], [0, 1, 1, 0]);
const y = useTransform(scroll, [c - 0.1, c - 0.05], [34, 0]);
const s = STAGES[i];
// Only mount the card while its stage's cross-fade window is open.
if (!useInWindow(opacity)) return null;
return (
<motion.div
className={`dm-st-card-story is-${side}`}
@@ -123,7 +144,9 @@ export default function StrategySection({ connected = false }: { connected?: boo
trigger: el,
start: "top top",
end: "bottom bottom",
scrub: 0.5,
// Match Workflow 1's responsiveness (0.4) for consistent pacing across all
// three workflows — 0.5 made the dolly feel slower / disconnected here.
scrub: 0.4,
invalidateOnRefresh: true,
onUpdate: (self) => {
const p = self.progress;
@@ -135,7 +158,7 @@ export default function StrategySection({ connected = false }: { connected?: boo
if (na !== lastActive) { lastActive = na; setActive(na); }
},
});
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
const refresh = setTimeout(() => ScrollTrigger.refresh(), 120);
return () => { clearTimeout(refresh); st.kill(); };
}, [scroll]);
@@ -154,10 +177,14 @@ export default function StrategySection({ connected = false }: { connected?: boo
<div className="dm-st-card">
{mountScene && (
<div className="dm-st-canvas">
<StrategyCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} stage={active} />
<StrategyCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive && pinState === "pinned"} stage={active} />
</div>
)}
{/* Overlay mounts only once the section is pinned/activated — its content
can never be seen during the approach ("before"), where the sticky sits
at the top of the tall section near the previous workflow's seam. */}
{pinState !== "before" && (
<div className="dm-st-ui">
{/* Persistent header */}
<motion.div className="dm-st-top" style={{ opacity: headerOpacity }}>
@@ -229,6 +256,7 @@ export default function StrategySection({ connected = false }: { connected?: boo
</div>
</StageCard>
</div>
)}
</div>
</div>
<style>{styles}</style>
@@ -237,8 +265,12 @@ export default function StrategySection({ connected = false }: { connected?: boo
}
const styles = `
.dm-st { position: relative; height: 720vh; background: transparent; }
.dm-st-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden; }
/* Scroll length tuned for pacing: ~100vh per stage (was 144vh) so the 5 stages
complete in noticeably less scrolling and the workflow feels tighter / faster.
Stage cross-fade windows are progress-based (0…1), so they stay aligned. */
.dm-st { position: relative; height: 500vh; background: transparent; }
.dm-st-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden;
will-change: transform; transform: translateZ(0); backface-visibility: hidden; }
.dm-st.is-pinned .dm-st-sticky { position: fixed; top: 0; left: 0; }
.dm-st.is-after .dm-st-sticky { position: absolute; top: auto; bottom: 0; }
@@ -257,6 +289,9 @@ const styles = `
.dm-st.is-connected .dm-st-card {
top: 20px !important; left: 20px !important; right: 20px !important; bottom: 0 !important;
border-radius: 28px 28px 0 0 !important; border-bottom: none !important;
/* Flush against the Strategy card below — drop the heavy downward shadow so it
doesn't cast a dark band onto that card's top edge (the two read as one container). */
box-shadow: none !important;
}
@media (max-width: 767px) {
.dm-st.is-connected .dm-st-card {
@@ -397,7 +432,7 @@ const styles = `
.dm-st-rail__line { width: 9px; }
}
@media (max-width: 767px) {
.dm-st { height: 640vh; }
.dm-st { height: 420vh; }
.dm-st-card-story { left: 0 !important; right: 0 !important; margin: 0 auto; width: calc(100% - 28px);
bottom: clamp(18px, 4vh, 40px); padding: 15px 16px; }
}