From b2d64bd335ad4a36d5e7b8cc35eaee05478b5bc4 Mon Sep 17 00:00:00 2001 From: Aravind R Date: Thu, 4 Jun 2026 14:51:13 +0530 Subject: [PATCH] fix scroll smooth --- public/css/custom-frontend.min.css | 2 +- public/css/vendor/vendor-theme-core.css | 2 +- public/images/preloader.png | Bin 0 -> 8207 bytes src/animations/SmoothScroll.tsx | 35 +++- src/app/globals.css | 34 ++-- src/app/layout.tsx | 1 + src/components/layout/LoadingScreen.tsx | 2 +- .../logisticsbrain/LogisticsBrainSection.tsx | 68 ++++++-- .../optimization/OptimizationSection.tsx | 157 ++++++++++-------- src/components/sections/MileTruthHero.tsx | 4 +- src/components/sections/Workflow1.tsx | 27 ++- src/components/sections/Workflow2.tsx | 27 ++- src/components/sections/Workflow3.tsx | 27 ++- src/components/strategy/StrategyCanvas.tsx | 59 ++++--- src/components/strategy/StrategySection.tsx | 53 +++++- 15 files changed, 331 insertions(+), 167 deletions(-) create mode 100644 public/images/preloader.png diff --git a/public/css/custom-frontend.min.css b/public/css/custom-frontend.min.css index 35bf2a4..28aa3d2 100644 --- a/public/css/custom-frontend.min.css +++ b/public/css/custom-frontend.min.css @@ -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; diff --git a/public/css/vendor/vendor-theme-core.css b/public/css/vendor/vendor-theme-core.css index 3ddbb95..50a4a2b 100644 --- a/public/css/vendor/vendor-theme-core.css +++ b/public/css/vendor/vendor-theme-core.css @@ -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 { diff --git a/public/images/preloader.png b/public/images/preloader.png new file mode 100644 index 0000000000000000000000000000000000000000..92cb60e0c521d2b4166ebcbbee8e1addec4d9c47 GIT binary patch literal 8207 zcmV+qAn@ObP)Nklj03*PR02l!VA=X>t@U3YJh-_o1VET1`41uy z^N|x$=A%%;L__;?t>qUYWq;3bEBV)u09XJ+6hW&HIj~~MtPiy2w^lHXSPlq)9Ec(S z7Auxa2h23kO5Sh%?}q~dtpuV7z{MoQGGO6>?&!A=$@TzI1fUp06HX39)0?g2Xpca& zKokL39%J0d9t1K@Ac_E7j$9zsPcUra0HO#$c?_B5rB2mbwEu()L=k{WkX;i!zJfT0 z2SgEoDiIop@GGR&;Q&ztpdMUmz4?DfKN0X6h#~;>0&(;lh#~;ZFci&CkXHMLA^<=W z^jOLM;O3nl_q%s{1iS%}A^;v>qQ+N5zwc}%!-PPiAW{Ut15DKTg4MNmZ*SdsOrTL9 ziU9aSPlR`oQa1}k5diPtZCh_d)YuFVMF9K+QDZ|u6any>ENa~E-g!u%7T`Vu5D3^0X10T;lKokK86!XG;zV+#Fhdkx&&Go0|?^yKjXHV1GNOI4l z-0zzle}pIk5HLR18Lew>OrnppDnbKh8ECCcUGn#Xx~6>~iU0(U?Djt$Js}TSkU}qq z6UouN?vp`PA_-9hAO@TPbF$^z?DX54cU}nbOR9q0ys-zw%5=CPiU7odhtBl=S9klY zWuNLHGjGV$B`@J{Llgms3%jRr%O_zp8e{8KfZk?sj| zkF+sYn5`i9$Y;*D_THq=N~iq}r9%RsAn97C=N^3NbfqI*^KNf`E?x5t-Xlf!=NsgI z%2o@eruT9)IeJAalI?WV8`M!C3BbOhLn>cQ%hdJU{#ENS{H}*iT!pPa1^!bWD=^;`CP4SQJ`U9=R2&9RO z*xMS|a1;R+7eJHtcBp?&{925Oo}Xl;doNqc4EwBF}xty$!U=DWmr7Z2|z|9(5~Hz864UyEHSbVw%=`#QYxc?uQa&2XcQ}v4_Q|n9w z#nuD$V4etOjn-HD_UKdYly4aGqnqWv9K7ROJ1eYIgD3*91bMXa#6*s}I_>PvNUN6+ z!ORG8QQ$-xEUx{9z@?Dg{_*n5cOi-ZtUw+)-ZrAP?P`w387`6z+f-PkYBR3|J{%b# z2m#CGGR^9!DFRR-V-aNOyLKN;*i_DFk+BPL#MxeHmgJf`XeMl>eD7v*@pU$uS zDg%fjXf3B{v0ER8g|tPniz@QQ^hL`R7XMlHpgs4N`B&zx{VD^9B4`zEf<)2wbXrVc zJ}XCqi(uyAGEct|#O=$r+$;j32%=}YK4{Dm3xseH&MCIF!AnPjbI{E1P1ITBwMoj*SzdN6;Y`Z?Ew%)P#Z3KNzDtEozr>gRIYW1J&r{? zT7Uj&S`7cTq0}FbvuL{pGb*bs_Ude?t`mqNsFZQ7kZ9XPFB{m>zXG{=a&{5IX^NcI z9&IbqkIje1+GeT*q6jMCT!9}f&m8l_iD82iJfi5CdBgjm?rD=cfGC2}m};;_t#EZ_ ziQ%og+oR`}8i!p5U>-EuQ&S7+xOP6|G+Nlfxk`$<}}VH82tSpo%G#)rrq=fhd9t zacv*k_M-7Nc661%3`cqCKgP#sF*3siv(I3PDraUZU})-WL-Tj-(F(VNYr)+Yk>_Qu z^SAX}-@ZPgKi`NYg+`mo;1#AEU*XQfP=`%LzgZ zZOe+-ZOrE#T04Ww9r;mwik#eS5q`2t-LMEg))r$*p&^Pb#jojznV4vcj2IqX_+s0? z`9H=0(l3_T9$hJOny*X$Px%%tx}L>eH-;1%qS!K=7`mFF*C#;dwycbzLsQ7=lPB1I zL;Lr=Uy~@fXz|9nt*Q3N6JklBA&M;_5HTE!%dGxA52HhqDtDgulb>tf8g18znL6qY zc|t5HG(<6SLJ`A7bvU~-kUQ!39z}<9Q~Ns5`cFTx`t2q8VlG9JLPHcI<+QcN^Lr;E z2TJs_S#`M6=JE7}H|>&R0dIE6sy!rcT=!k`v^*nEP@SuG?C&QE7>HtI1kze#$s?`V z=Pa`Za#K4bZ=&4BqrwFgA{IhK!qk!m-y5%m_C)i&nXa;l4)<@a z@3_y!m!@nR9nSeXH-$A^JX;hI{DKffih(nBV*QT{3$?7|HPG+Lt0ru}qQkSWeUl+y z-Uzw7-~DD|EBWBXxzbgDCMA1Wns~cL1 zM2Fow4_R43^eQ?GGF`q1lT8eSxKBQiT^@ueg3IG!!*T&+SLEdR?C5ahl+#o4iF`7} z2;z_;Kr8jueD9DC7}vf}fdWzVgrNEjPqEuN=Vq2w`kFl3j2)7@TokuC=jOV7 zV`@GUXY%Ss^)g_hrD3w^jk_;RTKO@M695_G36D8~Xw4Lrz9YW`(F*zY=BKy>ag0f! z$rEf7Hw6eH#Rds8RbCur`D&(Q_fk5KnppEy7tCzxihhjRsSbZj0XA_%6dNE!SJ_d- zOv!})wO3|}C2zNh=|1sAXszF}@+1Ka)NDceTr*ipDep|U6`CMgQMot2_i((#!7D+u zk!}C@a4U8}wB$3#UVNU$#OhIiAX2OeZ&S(wEb8HNW(t2XWdQ$*>vog(X7b?`wFx#U zs}J>Q<{)c|h!kreH&WgOWu#ba4Ni9!toX8@n=w=TdjVvIN^;aLK_oZw=Sv;0=i0FX z1Ce5doJLR*YJ0+v)t9GlZ+>IaF`h`tq${j8GLiRiMNbP1m3e69_bh&VU9J-Ul{`u3 zivm%s2p2)w75XNZ2^Kfl&qibT+&}PkU6~U^Hub}whtyWm^ng4uTT;4e1A?GtpdpGS z6G3zsE@MITd;eke{AEb=8~K1Jznb=ofSO{7cwAqLAGZ+p^F0V+wj9EI7?Kz09t8#> z#S-vxYjm4%?LsHf_aH8Xd6O`2&OK@vQ+k2X(l)%@3)m`Tw|{)}guoQcJoKXME8%5L z5XBX-tp?n=_BzDA!+0=)_z8kIgCY3&T^wb5R1`Cd}$^Pt$`bnVgZaGZf8pz z&}!2x5X7SR)S7fcYd0t$Ac_lc>9V~m63VTVSP>dP5SPY_=&VEXR|*KE&oe-}>=CXp z`{WUUW%1%Pfpk>Qd`#*0z|Y&?w)H@c_Ln7tZ!&Q<{F^BLTzo`;K4TM1^F zF9$;uR{@b?j%&u|Yj%-CSR1k9C4nGb5-o`_2dQx+toAjCBIWf%h2BG-5Z!6_`mZuT zq_`5TPoJ~t6!Gm0XRER254KvmAQm)Ft3}Z*b3pyjE3%s^v+uzGP&KVhpI!eU>8BJ2 z2~9hVT?$d?h4$KbQw8`%>qPN%yG`KB9P%5?DSdfAt6jy-obvS@W_e7$S(TL({Q+6Z=>9c0$g^N{P9WfL^ zG}wmo`^mqD1nR((siqX7SU1_)udB8xJB}^c<2{1&o0%t!l^{m?jCk)DK@4UCa|B?f ze+7bfpT~k2%TcK%7OS#y5n_)*zcd+Q8ChQv~c|wQMAq7Oxq8ObZF0okE87suw z6bROB#?(rUtoie1c|!6l1w^o-7@;7NXn9gd5M3QHq;?~?&9x)io76e9nf4$>F-Aem z7ONiZE&6@#bIhG zH%))~@Vv-xb#+7uYi!Y)D+HQB{AkBvmLUa3Frw&YnPZt$u`Ic1+RZhGwE9fs0Om?F z>~}Y;AbJuhsv!kN5TY2riO=Dmo;-B?pHux@iu<63CJX`CK; zf<3H8f#GeWnBP>1VnOT(+j>)^7jcAhn}ai!WEYbw&Ek{u9O5xDFGX=;rG7L6?L`;G zjtJ3hwjfIM0AK3Q!Uq%}eu$!RD!)rU{$d{nwNk$*D;#+bYT3gGkv&9-&9eX1-9C8& zvxX?HM7DHvyf-ko){{l7aO8de)}4nzHEHK4(K`eh#V?a3ymlN*fcj=M#?M$0*zzpi4Vvd!kc*>QNJjjIf8B+Dl3RRiUqfJ z|Bs~w(MHQ5nkyEx;rM}&KlONK9#EjvUQ-m2=c9>^ynYtV8n9K36-%(uvd{o+IL(vY zWbx;<6spN$sWnoZoanIA8nKP=t$K$f+mje(v}``-T>{P1@vCbid!T@+OB9o-I%0eiD=Tl9&PoWuJ{NnTI*c*l zQfH6)dqbUM8BvQUvZj|)4cajYB4rKev;uhxPOyt}mFB|BEkJcB<&*2|vZkQFFKRGa z+3$qal2ks{LO*kP>( z1RBN1DJZ4&E=54uqL?6Uj-Ql1qn_9>-o3U=>Kx0G;ZjABwd}xcj+}t^ZH|?2_vFQbj~Q4jsk5atQM9Y~N00N_W)LAE@5ze+FQdeWQfEs^qA0tg zC&k`cA*9XG67rtB81S-AAKB*hQbcjeTp|6GK(vHD*BUau?%fnEKTt$mx=+#W5kya7 zTaW}MOLKS`B}US)J;~)FMSG8gPV$R4r)+jTq;t`CReh^fmxzD zQ3@`A;XF5i&!=x=oPl3GQk=N;>uMcL4+%sK6AcN}j=uqi?8Z0)zp^M!hRv84Au*q} zU0xIsCAOBU^yQd9EjYK?qYHggOlUxiEG>$N65Gpn$;V#^RF2PXvn9qhoJp1wMMQ}J zREiTspLYjD9wl0q5=BIbZALK3QUgBM5iPMtiI(D`h$yk;FhsFpysjf!S`Z~#3X3A5 zL;$M83Zl<-L`xf@L`$(Tzih_&jeY3d@$K?S@*g951VDwDI^wqPg4mWQ(NatlSq59j z`HXX_pW2JpQDRTC3UAG7O8sZ|_Sg2~39a4etjS-;|C*>?_JF(Q|GMVS=o5RK1_5u_ z@BVFrRr(`&PFtcxOCeF5+?QycooHvACr`6p#6(NV+18Y)q4>egJ3Tik48R<6LG*u1 zeT$+*%i@}%$ljS_RFNXds6=pSn0o}k93}*@OP(`%YNQP;6h$R^$9~-JZkR6~$Giz= zue1fKK0Ao3(<4v{AqnE-sgbrYUlb<~(sX{ef*8OTaW%?XslMO+W`h8jfiqQ9`i=s5 zEycPCXC8A!(fBDljy~90K~3VXz!~;$uJ17EaJJJ=+;MLkv)$*3eQB)qvHGX>(J`V9f`NgWDzqX#p!)%+^1L(Iw&#%NbDjM0*-T{IF&lbIj%M)rOzd&3T-eVuJi~v1JXLn`O2@LzwWH5l}*~SIF~-( zPY&CWI=ep1atq2L(u>)YvZxN*XtZnwS5*^hfziTUyB@8ytwJ7PBSNL* zK_-OMcNLI`%OJ*BdDc&UZf#w75o1xD9(RkoAFYadqD!@+FDPaxr8X&Gv&DU_jFG4$ z{Z>dlG+(z~>_RxgHfV1qM=)0Aa4L%VxLf|>j$0Hp<6ZI+h>)Ff^8SV8dMdA6>5=Yi zI03ZFI>f%=i)}YE|0{m>JPu?R4cdzti(*P&vaJ}gx8jifT)F%WAZx~C(bNtm>WW!2 z_w{DpL_3+WrNmxVl&*}HFSzw>u4lAozS!;;oB*~XkiQs_gZ}Z+69Nm#L~){Lk410B zeThzztvMI_KoM+R@owMLpa(_}yR4*{i*z#iVd8rhhZLuAPP2MMpb##9F!jWVbTaN% zoC-~rR-wqgc@pL3+>n5uEVyM3&Xb9vRs;Jnt0%_YYd8G{09HjNwkPq#ygcv-2;xdO zQ5??zcXafPxuZ9L(rH2C_7qGTqeE6&m3PholmrBEWmMLmnAH=N@Fqo1+UfOKv#&iEzZL^mE28JGeA?YmURD(7Obh2ZDHM6f+OTKZ|@- zm|?ip@>T1l!qi6!?GX^fQc$c@XFS-^Es7n1OIkKx=*qVJ=u&8D?@F=85jlV$mV-KN z+U~?}vcP#R9Z|eJddytj5iqa}vMX`|K`aS%YO+Y|tMm>H&d}*`ba#zvSr}mLE zTo5f*htME1RIm@RDzZp1@+0mezDqv-LY2{JbrdR!o{b-;+^QQcbzyfQ_Ky!?s8mcQ zitQLt8xp9HY;#Wb__}qWQK~2llP)Wr_OT1G9-JsD(O*;SRNSM8cW>8{V{afk!-Yw9 z2wWQLE{lA=SJ6!&Z~;yfr4=SiEwnp2V(Oo1&q1`fI*Dj`WZh-7+tf>zJ}ZviWFbDC z@ve~8fAb<*ygV*C)Squ}B}Zt6Z-7xvG5#LKdTNSzM@O`{I-}YlqQgcR^(n?5Db|71 z5{-HfqQ#|2+}Y9N!`rAGHjm1;ZO2a?6DN$Q$bo3FFr!o%joAL6+!Z=d3QV~FG6K=! z0&Eku=G#b>{X>!HR;13H>LFE(X8x5x#zeHZ`RV8t*0$vsF|>6;db8~o)AOS4gE&7qH()6{xT$RiisBA-n#SHK5XesbduE!`8LEb92B*L zl&Vi{-Rdlu3R8&i%kc)Cw9}R~SBafdb_R;886r&%2_=!=c^+|I{#jE^+}rJ`AWVPOJi za4kA_9@=?c`(toxeGt@1U6UHK$Uu|4*-Abj8;BT)V%emB9+3Ktz{bd*L5oIZ97(7Z-^j#{vV%S59hnp>V5zK002ovPDHLkV1mpT Bdj$Xh literal 0 HcmV?d00001 diff --git a/src/animations/SmoothScroll.tsx b/src/animations/SmoothScroll.tsx index cd9da50..a3cfae5 100644 --- a/src/animations/SmoothScroll.tsx +++ b/src/animations/SmoothScroll.tsx @@ -66,13 +66,34 @@ export default function SmoothScroll() { gsap.registerPlugin(ScrollTrigger); - const lenis = new Lenis({ - duration: 1.05, - easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), - orientation: "vertical", - gestureOrientation: "vertical", - smoothWheel: true, - }); + // /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 }); diff --git a/src/app/globals.css b/src/app/globals.css index b8f6b34..ab4aa19 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 97a7ea3..e1eb02e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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"; diff --git a/src/components/layout/LoadingScreen.tsx b/src/components/layout/LoadingScreen.tsx index af411a4..c5e018c 100644 --- a/src/components/layout/LoadingScreen.tsx +++ b/src/components/layout/LoadingScreen.tsx @@ -83,7 +83,7 @@ export default function LoadingScreen() { height={38} priority className="dm-loader__logo" - style={{ height: "auto" }} + style={{ width: "auto", height: "auto" }} /> diff --git a/src/components/logisticsbrain/LogisticsBrainSection.tsx b/src/components/logisticsbrain/LogisticsBrainSection.tsx index 4f531cb..806538e 100644 --- a/src/components/logisticsbrain/LogisticsBrainSection.tsx +++ b/src/components/logisticsbrain/LogisticsBrainSection.tsx @@ -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 }) { return {Math.round(mv.get())}; } +/** 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, 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 (
@@ -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
{mountScene && (
- +
)} + {/* 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" && (
{/* Persistent header: what this is + where we are in the workflow */} @@ -281,6 +308,7 @@ export default function LogisticsBrainSection({ connected = false }: { connected
+ )}
@@ -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; } diff --git a/src/components/optimization/OptimizationSection.tsx b/src/components/optimization/OptimizationSection.tsx index caaaa4e..5d0720f 100644 --- a/src/components/optimization/OptimizationSection.tsx +++ b/src/components/optimization/OptimizationSection.tsx @@ -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 ( + + + Live Analytics: {orders} Orders + + AI Accuracy: {accuracy}% + + Fleet: {activeVehicles}/5 EV Active + + Route Health: {routeHealth}% + + Carbon: {carbon}% + + ); +}); + export default function OptimizationSection() { const containerRef = useRef(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"} /> )} @@ -382,25 +410,9 @@ export default function OptimizationSection() { {/* KPI metrics */}
- {/* Bottom insight bar */} - - - Live Analytics: {orders} Orders - - AI Accuracy: {accuracy}% - - Fleet: {activeVehicles}/5 EV Active - - Route Health: {routeHealth}% - - Carbon: {carbon}% - + {/* Bottom insight bar — isolated so its live tickers don't re-render + the whole section (and never fight the scroll handler). */} +
@@ -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; } diff --git a/src/components/sections/MileTruthHero.tsx b/src/components/sections/MileTruthHero.tsx index ca33911..25307f4 100644 --- a/src/components/sections/MileTruthHero.tsx +++ b/src/components/sections/MileTruthHero.tsx @@ -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; diff --git a/src/components/sections/Workflow1.tsx b/src/components/sections/Workflow1.tsx index 69775b8..e572924 100644 --- a/src/components/sections/Workflow1.tsx +++ b/src/components/sections/Workflow1.tsx @@ -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 (
@@ -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 ── */} -
+
setPaused(true)} onMouseLeave={() => setPaused(false)}> {/* Left Column: Overlapping Chevron Graphic */}
@@ -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; diff --git a/src/components/sections/Workflow2.tsx b/src/components/sections/Workflow2.tsx index 42e40d7..468bdae 100644 --- a/src/components/sections/Workflow2.tsx +++ b/src/components/sections/Workflow2.tsx @@ -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 (
@@ -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 ── */} -
+
setPaused(true)} onMouseLeave={() => setPaused(false)}> {/* Left Column: Overlapping Chevron Graphic */}
@@ -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; diff --git a/src/components/sections/Workflow3.tsx b/src/components/sections/Workflow3.tsx index e14882a..e581c37 100644 --- a/src/components/sections/Workflow3.tsx +++ b/src/components/sections/Workflow3.tsx @@ -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 (
@@ -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 ── */} -
+
setPaused(true)} onMouseLeave={() => setPaused(false)}> {/* Left Column: Overlapping Chevron Graphic */}
@@ -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; diff --git a/src/components/strategy/StrategyCanvas.tsx b/src/components/strategy/StrategyCanvas.tsx index 424fe32..b2a2c3d 100644 --- a/src/components/strategy/StrategyCanvas.tsx +++ b/src/components/strategy/StrategyCanvas.tsx @@ -123,7 +123,10 @@ function useLabelFade(i: number, progress: React.RefObject, 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; 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 ( @@ -331,7 +334,7 @@ function RiderAvatar({ rider, register, awake }: { rider: typeof RIDERS[number]; - {awake && ( + {focused && (
{rider.icon} @@ -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; reduced: boolean; awake: boolean }) { +const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced, awake, focused }: { i: number; progress: React.RefObject; reduced: boolean; awake: boolean; focused: boolean }) { const register = useLabelFade(i, progress, awake); const counter = useRef(null); const halo = useRef(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) */} - {RIDERS.map((r) => )} + {RIDERS.map((r) => )} - {awake && ( + {focused && ( <>
📄 orders.csv
@@ -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; reduced: boolean; awake: boolean }) { +const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduced, awake, focused }: { i: number; progress: React.RefObject; reduced: boolean; awake: boolean; focused: boolean }) { const register = useLabelFade(i, progress, awake); const ring = useRef(null); const coreMesh = useRef(null); @@ -450,7 +453,7 @@ const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduc - {awake && ( + {focused && (
🤖 AI Engine
@@ -465,7 +468,7 @@ const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduc {/* flat glowing route node (consistent with the dispatch network) */} - {awake && ( + {focused && (
{l.name}
@@ -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; reduced: boolean; awake: boolean }) { +const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced, awake, focused }: { i: number; progress: React.RefObject; 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 && } - {awake && ( + {focused && ( <>
🏢 Dispatch Hub
@@ -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; awake: boolean }) { +const CommandCenter = React.memo(function CommandCenter({ i, progress, awake, focused }: { i: number; progress: React.RefObject; 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 }: - {awake && ( + {focused && (
{s.n} @@ -643,7 +646,7 @@ const CommandCenter = React.memo(function CommandCenter({ i, progress, awake }: ))} - {awake && ( + {focused && (
Performance Grade A · 4.5 / 5
@@ -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 - {awake && ( + {focused && (
{p.v}% {p.n}
@@ -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; reduced: boolean; isMobile: boolean; awake: boolean }) { +const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, isMobile, awake, focused }: { i: number; progress: React.RefObject; reduced: boolean; isMobile: boolean; awake: boolean; focused: boolean }) { const register = useLabelFade(i, progress, awake); const trophy = useRef(null); useFrame((state) => { @@ -697,7 +700,7 @@ const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, is - {PODIUM.map((p) => )} + {PODIUM.map((p) => )} @@ -706,9 +709,9 @@ const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, is - {awake && !reduced && } + {awake && !reduced && } - {awake && ( + {focused && (
🏆 Best Strategy @@ -818,14 +821,18 @@ function Scene({ progress, reduced, isMobile, stage, active, perf }: { progress: - - - - - + {/* `awake` (±1) keeps geometry/shaders/animation warm for a pop-free + transition; `focused` (current district only) gates the expensive + in-canvas labels so only ~one district's worth project + write + to the DOM each frame instead of three. */} + + + + + {heavyFx && ( - + )} {heavyFx && ( diff --git a/src/components/strategy/StrategySection.tsx b/src/components/strategy/StrategySection.tsx index db940a4..a0ef62a 100644 --- a/src/components/strategy/StrategySection.tsx +++ b/src/components/strategy/StrategySection.tsx @@ -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, 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 ( { 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
{mountScene && (
- +
)} + {/* 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" && (
{/* Persistent header */} @@ -229,6 +256,7 @@ export default function StrategySection({ connected = false }: { connected?: boo
+ )}
@@ -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; } }