window.addEventListener("load", function () { accordion(); fresh_platform_showcase(); hero_intro(); fresh_logo_spin(); scroll_reveals(); mfl_card_video(); button_label_roll(); // --------------------------------------------------------------------------- // Accordion // --------------------------------------------------------------------------- function accordion() { // Setup $(".accordion").each(function (index) { let current_accordion_card = $(this); current_accordion_card.attr("id", `accordion-no-${index}`); let accordion_body = current_accordion_card.find(".accordion-body"); let current_height = accordion_body.css("height"); accordion_body.css("height", "0px"); accordion_body.find("p").css("opacity", 0); current_accordion_card.attr({ expanded_height: current_height, is_collapsed: true, }); }); $("#BENEFITS .feature-grid").each(function () { let first_accordion = $(this).find(".accordion").eq(0); open_accordion(first_accordion); }); $(".accordion").on("click", function () { let current_accordion_card = $(this); let is_close = current_accordion_card.attr("is_collapsed"); if (is_close === "true") { open_accordion(current_accordion_card); } else { close_accordion(current_accordion_card); } }); // ------------------------------------------------------------------------- // expanded_height is measured ONCE, above, at whatever width the page // happened to load at. Resize after that and the number is stale: the copy // rewraps but the stored height does not follow, so an opened card is sized // for a line count it no longer has. // // It is not a small drift either, because .feature-grid goes 1-col at 991. // Crossing that breakpoint makes the accordion column JUMP from half-width // to full-width, so the same answer needs wildly different heights: // // 390px -> 270px tall 820px -> 135px tall 1052px -> 216px tall // // Measured: a page loaded at 1052 and resized to 820 leaves every card // 54-108px too tall — 81px of dead space under the copy on the first one. // That is the gap. It never shows on a normal visit because nobody resizes; // it shows constantly in a QA tool that switches device widths in place. // // Width only. A height-only resize is the mobile URL bar sliding away, and // re-measuring on that would thrash on every scroll. // ------------------------------------------------------------------------- let accordion_resize_timer = null; let last_accordion_width = window.innerWidth; $(window).on("resize", function () { if (window.innerWidth === last_accordion_width) return; last_accordion_width = window.innerWidth; clearTimeout(accordion_resize_timer); accordion_resize_timer = setTimeout(remeasure_accordions, 200); }); function remeasure_accordions() { $(".accordion").each(function () { let current_accordion_card = $(this); let accordion_body = current_accordion_card.find(".accordion-body"); if (!accordion_body.length) return; let body_element = accordion_body[0]; let is_open = current_accordion_card.attr("is_collapsed") === "false"; // Read at auto height and put it straight back. Both writes land in the // same frame, so the browser never paints the intermediate state. let restore = body_element.style.height; body_element.style.height = "auto"; let natural_height = body_element.scrollHeight; body_element.style.height = restore; current_accordion_card.attr("expanded_height", natural_height + "px"); // An open card is showing the stale number right now — correct it. if (is_open) { gsap.killTweensOf(body_element); body_element.style.height = natural_height + "px"; } }); } function checking_accordions(current_accordion) { let main_container = current_accordion.closest(".feature-grid"); let total_open_accordion = main_container.find( ".accordion[is_collapsed='false']" ).length; if (total_open_accordion === 1) return; let card_to_close = main_container .find(`.accordion[is_collapsed='false']`) .not(current_accordion); close_accordion(card_to_close); } function close_accordion(current_accordion) { let accordion_body = current_accordion.find(".accordion-body"); let accordion_body_text = accordion_body.find("p"); let space = current_accordion.find(".accordion-space"); let arrow_svg = current_accordion.find(".accordion-icons"); gsap .timeline({ onStart: () => { gsap.set(current_accordion, { pointerEvents: "none", }); current_accordion.attr({ is_collapsed: true, }); }, onComplete: () => { gsap.set(current_accordion, { pointerEvents: "auto", }); }, }) .to(accordion_body_text, { opacity: 0, duration: 0.45, }) .to( space, { height: "0rem", duration: 0.7, }, "<" ) .to(accordion_body, { height: "0px", duration: 0.45, }) .to( arrow_svg, { rotateZ: 0, duration: 0.45, }, "<" ); } function open_accordion(current_accordion) { let accordion_body = current_accordion.find(".accordion-body"); let accordion_body_text = accordion_body.find("p"); let space = current_accordion.find(".accordion-space"); let arrow_svg = current_accordion.find(".accordion-icons"); let expanded_height = current_accordion.attr("expanded_height"); gsap .timeline({ onStart: () => { gsap.set(current_accordion, { pointerEvents: "none", }); $(".accordion").attr("is_current_card", "false"); current_accordion.attr({ is_collapsed: false, }); checking_accordions(current_accordion); }, onComplete: () => { gsap.set(current_accordion, { pointerEvents: "auto", }); }, }) .to(accordion_body, { height: expanded_height, duration: 0.45, }) .to( space, { height: "0.7rem", duration: 0.7, }, "<" ) .to( arrow_svg, { rotateZ: 180, duration: 0.45, }, "<" ) .to( accordion_body_text, { opacity: 1, duration: 0.45, }, ">-0.1" ); } } // --------------------------------------------------------------------------- // Fresh platform showcase // // Three rows, one open at a time, morphed with Flip in three phases: the // outgoing copy leaves, the cards swap size, the incoming copy arrives. // // On desktop the section also PINS and the wheel/trackpad walks the three // rows — one step per ~0.85 viewport heights, snapped so a trackpad flick // lands on a step instead of between two. Scroll, the chevron buttons and a // click on a card are all the same journey: every one of them routes through // go_to_index(), which moves the page to the matching step, so the scroll // position and the open row can never disagree. // // Below 768 the layout drops .fresh-platform_slide-nav and stacks the grid // into one column, so there is nothing to drive the accordion with — every // item is simply left open. // --------------------------------------------------------------------------- function fresh_platform_showcase() { if (!window.Flip) return; let start_index = 0; let resize_timer = null; let showcase_query = window.matchMedia("(min-width: 768px)"); // --- scroll stepping state --- let step_dwell = 0.85; // viewport heights of scroll per step let is_seeking = false; // we are scrolling the page ourselves let active_transition = null; // the one timeline allowed to run at a time inject_showcase_styles(); // Setup $(".fresh-platform_videos-wrapper").each(function (index) { let current_wrapper = $(this); current_wrapper.attr("id", `fresh-showcase-no-${index}`); current_wrapper .find(".fresh-platform_videos-item") .each(function (item_index) { let current_item = $(this); let item_text = current_item.find( ".fresh-platform_video-side.is-text p" ); item_text.each(function () { let current_text = $(this); current_text.attr("base_opacity", current_text.css("opacity")); }); current_item.attr({ item_index: item_index, is_collapsed: true, }); }); gsap.set(current_wrapper.find(".fresh-platform_video-side"), { transformOrigin: "50% 50%", }); }); apply_showcase_mode(); if (showcase_query.addEventListener) { showcase_query.addEventListener("change", apply_showcase_mode); } else { showcase_query.addListener(apply_showcase_mode); } $(window).on("resize", function () { clearTimeout(resize_timer); resize_timer = setTimeout(function () { if (!showcase_query.matches) return; $(".fresh-platform_videos-wrapper").each(function () { lock_wrapper_height($(this)); }); // the pin's start/end are viewport-height derived if (window.ScrollTrigger) ScrollTrigger.refresh(); }, 200); }); $(".fresh-platform_slide-btn").on("click", function () { if (!showcase_query.matches) return; let current_button = $(this); let current_wrapper = current_button.closest( ".fresh-platform_videos-wrapper" ); let direction = current_button.index() === 0 ? -1 : 1; go_to_index( current_wrapper, get_current_index(current_wrapper) + direction ); }); $(".fresh-platform_video-side.is-video").on("click", function () { if (!showcase_query.matches) return; let current_item = $(this).closest(".fresh-platform_videos-item"); go_to_index( current_item.closest(".fresh-platform_videos-wrapper"), Number(current_item.attr("item_index")) ); }); function inject_showcase_styles() { if (document.getElementById("fresh-platform-showcase-css")) return; let showcase_styles = document.createElement("style"); showcase_styles.id = "fresh-platform-showcase-css"; showcase_styles.textContent = ` /* logo spins while the button is hovered, and freezes where it stopped */ .fresh-platform_slide-btn-logo{ transform-origin:50% 50%; animation:fresh_slide_btn_spin 2.4s linear infinite; animation-play-state:paused; } .fresh-platform_slide-btn:hover .fresh-platform_slide-btn-logo{ animation-play-state:running; } @keyframes fresh_slide_btn_spin{ from{transform:rotate(0deg);} to{transform:rotate(360deg);} } /* The blue overlay's middle stop is rgba(189,246,244,0.2) — 80% see-through — so whatever sits behind it reads through the pill, which is why only the top one leaked. White is what that gradient was drawn over. The expanded card is unaffected: the video covers the inner completely. */ .fresh-platform_video-inner{ background-color:#fff; } /* the collapsed pills are the affordance, so say so */ .fresh-platform_video-side.is-video.is-collapsed{ cursor:pointer; } /* THE COLLAPSED STATE ITSELF. .is-collapsed / .is-collasped are added at RUNTIME by this script and never appear on an element in the Designer, so Webflow reads them as unused and Clean Up prunes them from the published stylesheet. When that happened, every row rendered at the open 465x408 and the section became three full-size cards stacked in a column. Verified against the original geometry: with these two rules the wrapper measures 552px and the rows 408 / 56 / 56 with a 1rem gap, which is exactly what it was before the styles were lost. They live here, with the JS that applies the classes, so a future Clean Up cannot take them again. Note the misspelling on the text side is load-bearing — the Webflow class really is is-collasped. */ /* WIDTH ONLY — no min/max. Flip morphs the card by animating width, so it has to be able to hold every value between 310 and 465 on the way. Pinning min-width and max-width to the same number meant asking for 400 gave back 310: the width could not move at all, so the morph snapped instead of animating. justify-items:center already stops the track stretching it, so width on its own is enough. */ .fresh-platform_video-side.is-video.is-collapsed{ height:3.5rem; width:19.375rem; /* 310px */ } /* the middle pill is the wider one: 390px. No breakpoint gate needed — at <=991 the open card is 25rem/400px, so 390 still sits inside it, and below 768 open_every_item() strips these classes entirely. */ .fresh-platform_videos-item[position="center"] .fresh-platform_video-side.is-video.is-collapsed{ width:24.375rem; /* 390px */ } /* ONE SHARED CENTRE LINE. Every row is its own grid, and a bare 1fr track carries an implicit min-width:auto — so the open row's column was being forced out to 465px by the card inside it while the collapsed rows sat at 415px. Different column widths, different centres: the open card centred on 281 and the pills on 256, so the card appeared to grow rightward off the pill's left edge rather than opening from the middle. minmax(0,1fr) drops that min-content floor, so all three rows get identical tracks and share one centre. The open card overflows its 415px track and justify-items:center spreads that overflow evenly, which is exactly what makes it read as opening from the centre. Gated at 768 so the single-column mobile layout is untouched. */ @media (min-width: 768px){ .fresh-platform_videos-item{ grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); } } .fresh-platform_video-side.is-text.is-collasped{ height:0; overflow:hidden; } `; document.head.appendChild(showcase_styles); } function apply_showcase_mode() { $(".fresh-platform_videos-wrapper").each(function () { let current_wrapper = $(this); if (active_transition) { active_transition.kill(); active_transition = null; } if (showcase_query.matches) { paint_showcase_state(current_wrapper, start_index); lock_wrapper_height(current_wrapper); } else { open_every_item(current_wrapper); } }); // the pin has to go before the layout stacks, and come back after if (showcase_query.matches && !is_reduced_motion()) { build_scroll_steps(); } else { kill_scroll_steps(); } } // below 768 there is no nav to drive this, so nothing is collapsed function open_every_item(current_wrapper) { let all_items = current_wrapper.find(".fresh-platform_videos-item"); current_wrapper.css("min-height", ""); gsap.killTweensOf(current_wrapper.find("*")); gsap.set(current_wrapper, { clearProps: "pointerEvents" }); all_items.each(function () { let this_item = $(this); this_item.attr("is_collapsed", false); this_item .find(".fresh-platform_video-side.is-video") .removeClass("is-collapsed"); this_item .find(".fresh-platform_video-side.is-text") .removeClass("is-collasped"); this_item.find(".fresh-platform_video-overlay").css("opacity", 0); this_item.find(".fresh-platform_video-inner-ele").css("opacity", 1); this_item .find(".fresh-platform_video-side.is-text") .css("overflow", ""); this_item .find(".fresh-platform_video-side.is-text p") .each(function () { let current_text = $(this); current_text.css({ opacity: current_text.attr("base_opacity"), transform: "none", }); }); play_video(this_item.find("video")); }); } // the wrapper is the same height whichever item is open, so a standing // min-height stops any single frame of collapse function lock_wrapper_height(current_wrapper) { current_wrapper.css("min-height", ""); current_wrapper.css("min-height", `${current_wrapper.height()}px`); } function get_current_index(current_wrapper) { let open_item = current_wrapper.find( ".fresh-platform_videos-item[is_collapsed='false']" ); return open_item.length ? Number(open_item.attr("item_index")) : -1; } // ------------------------------------------------------------------------- // Scroll stepping // // One pinned ScrollTrigger per wrapper. Progress maps to a row index and // snap settles the page onto whichever one is nearest, so the wheel never // parks the reader halfway between two states. // ------------------------------------------------------------------------- function build_scroll_steps() { if (!window.ScrollTrigger) return; $(".fresh-platform_videos-wrapper").each(function () { let current_wrapper = $(this); if (current_wrapper.data("step_trigger")) return; let section = current_wrapper.closest(".section-wrapper-card")[0]; if (!section) return; let total_items = current_wrapper.find( ".fresh-platform_videos-item" ).length; if (total_items < 2) return; let last_step = total_items - 1; let step_trigger = ScrollTrigger.create({ trigger: section, // A section taller than the viewport pins on its bottom edge. That // trims only the empty top padding — the heading still lands at the // top of the screen with all three rows below it. A shorter one // centres instead. start: function () { return section.offsetHeight >= window.innerHeight ? "bottom bottom" : "center center"; }, end: function () { return ( "+=" + Math.round(last_step * window.innerHeight * step_dwell) ); }, pin: section, pinSpacing: true, anticipatePin: 1, invalidateOnRefresh: true, snap: { snapTo: 1 / last_step, duration: { min: 0.15, max: 0.4 }, delay: 0.05, ease: "power1.inOut", }, onUpdate: function (self) { // ignore the frames we are generating ourselves in go_to_index if (is_seeking) return; let all_items = current_wrapper.find(".fresh-platform_videos-item"); let raw = self.progress * last_step; let cur = get_current_index(current_wrapper); let next = cur; // Hysteresis. A plain Math.round flips at exactly .5, so a reader // resting on a step boundary would ping-pong two transitions // against each other. Moving up needs .6, moving back needs .4 — // a 0.2 dead zone that a wheel notch cannot straddle. if (cur < 0) next = Math.round(raw); else if (raw >= cur + 0.6) next = Math.min(last_step, Math.round(raw)); else if (raw <= cur - 0.6) next = Math.max(0, Math.round(raw)); if (next !== cur) activate_item(all_items.eq(next)); }, }); current_wrapper.data("step_trigger", step_trigger); }); ScrollTrigger.refresh(); } function kill_scroll_steps() { $(".fresh-platform_videos-wrapper").each(function () { let current_wrapper = $(this); let step_trigger = current_wrapper.data("step_trigger"); if (!step_trigger) return; step_trigger.kill(true); current_wrapper.removeData("step_trigger"); }); is_seeking = false; if (window.ScrollTrigger) ScrollTrigger.refresh(); } // The single entry point. Scroll, buttons and card clicks all come through // here, so the page position and the open row stay in step. function go_to_index(current_wrapper, target_index) { let total_items = current_wrapper.find( ".fresh-platform_videos-item" ).length; if (!total_items) return; let step_trigger = current_wrapper.data("step_trigger"); // No scroll driver (tablet, or reduced motion): keep the original // wrap-around, so neither chevron ever dead-ends. let all_items = current_wrapper.find(".fresh-platform_videos-item"); if (!step_trigger) { let wrapped = ((target_index % total_items) + total_items) % total_items; activate_item(all_items.eq(wrapped)); return; } // Pinned: clamp instead of wrapping. Wrapping here would throw the page // back to the top of the pin, which reads as a scroll glitch. let clamped = Math.max(0, Math.min(total_items - 1, target_index)); activate_item(all_items.eq(clamped)); let destination = step_trigger.start + (clamped / (total_items - 1)) * (step_trigger.end - step_trigger.start); let scroll_proxy = { y: step_trigger.scroll() }; is_seeking = true; gsap.to(scroll_proxy, { y: destination, duration: 0.5, ease: "power2.inOut", overwrite: true, onUpdate: function () { step_trigger.scroll(scroll_proxy.y); }, onComplete: function () { is_seeking = false; }, }); } function play_video(current_video) { current_video.each(function () { let video_element = this; let played = video_element.play && video_element.play(); if (played && played.catch) { played.catch(() => { // not buffered yet — pick it up as soon as it is video_element.addEventListener("canplay", function play_once() { video_element.removeEventListener("canplay", play_once); let retried = video_element.play(); if (retried && retried.catch) retried.catch(() => {}); }); }); } }); } function pause_video(current_video) { current_video.each(function () { if (this.pause) this.pause(); }); } function checking_items(all_items, current_item) { all_items.each(function () { let this_item = $(this); let is_current = this_item.is(current_item); this_item.attr("is_collapsed", !is_current); this_item .find(".fresh-platform_video-side.is-video") .toggleClass("is-collapsed", !is_current); this_item .find(".fresh-platform_video-side.is-text") .toggleClass("is-collasped", !is_current); }); } // no animation — used on load and on every breakpoint change function paint_showcase_state(current_wrapper, target_index) { let all_items = current_wrapper.find(".fresh-platform_videos-item"); let current_item = all_items.eq(target_index); let current_color = current_item.attr("video-color"); checking_items(all_items, current_item); all_items.each(function () { let this_item = $(this); let is_current = this_item.is(current_item); this_item .find(".fresh-platform_video-overlay") .css("opacity", is_current ? 0 : 1); this_item .find(".fresh-platform_video-inner-ele") .css("opacity", is_current ? 1 : 0); this_item .find(".fresh-platform_video-side.is-text p") .each(function () { let current_text = $(this); current_text.css({ opacity: is_current ? current_text.attr("base_opacity") : 0, transform: "none", }); }); if (is_current) { play_video(this_item.find("video")); } else { pause_video(this_item.find("video")); } }); current_wrapper .closest(".section-wrapper-card") .find(".fresh-platform_bfs-bg") .each(function () { let current_bg = $(this); current_bg.css( "opacity", current_bg.attr("video-color") === current_color ? 1 : 0 ); }); } // ONE timeline per transition, and it can be interrupted. // // The previous version chained three tweens through onComplete callbacks // and locked the wrapper until the last one landed. Scroll outran it // constantly: the pin is 1.7 viewports and a trackpad flick crosses that in // less than the ~1.1s a transition takes. So requests queued, replayed // late, and every patch to the queue added another way to be wrong. // // Now a new request simply kills whatever is running and starts again from // wherever the cards visually are. Flip.getState() reads rendered // positions — mid-animation included — so the morph continues from the // current frame instead of snapping. No lock, no queue, nothing to flush. function activate_item(current_item) { if (!showcase_query.matches) return; if (current_item.attr("is_collapsed") === "false") return; let current_wrapper = current_item.closest( ".fresh-platform_videos-wrapper" ); let all_items = current_wrapper.find(".fresh-platform_videos-item"); let other_items = all_items.not(current_item); let previous_item = other_items.filter("[is_collapsed='false']"); let current_overlay = current_item.find(".fresh-platform_video-overlay"); let current_media = current_item.find(".fresh-platform_video-inner-ele"); let current_text = current_item.find( ".fresh-platform_video-side.is-text p" ); let current_video = current_item.find("video"); let current_color = current_item.attr("video-color"); // "other", not "previous": an interrupted transition can leave a THIRD // row with its copy half-visible or its overlay half-faded. Everything // that is not the target is driven to the closed look, from wherever it // currently is. let other_overlay = other_items.find(".fresh-platform_video-overlay"); let other_media = other_items.find(".fresh-platform_video-inner-ele"); let other_text = other_items.find(".fresh-platform_video-side.is-text p"); let previous_video = previous_item.find("video"); let all_sides = all_items.find(".fresh-platform_video-side"); let all_text_sides = all_items.find(".fresh-platform_video-side.is-text"); let backgrounds = current_wrapper .closest(".section-wrapper-card") .find(".fresh-platform_bfs-bg"); // ---- interrupt -------------------------------------------------------- if (active_transition) { active_transition.kill(); active_transition = null; } // A row that was mid-handover when this interrupted it would otherwise // keep decoding behind its overlay. The row visibly handing over now is // left running until the end, so it does not freeze mid-fade. pause_video(other_items.not(previous_item).find("video")); // ---- capture, then commit the new layout ---------------------------- // Flip.from() applies the inverse transforms synchronously, so the cards // hold their old look until the timeline reaches morph_at. That is what // keeps "the outgoing copy leaves before anything moves" without needing // a callback chain. let flip_state = Flip.getState( all_items.toArray().concat(all_sides.toArray()), { props: "borderRadius" } ); checking_items(all_items, current_item); play_video(current_video); gsap.set(all_text_sides, { overflow: "hidden" }); let morph_at = previous_item.length ? 0.2 : 0; let tl = gsap.timeline({ onComplete: function () { gsap.set(all_text_sides, { clearProps: "overflow" }); pause_video(previous_video); if (active_transition === tl) active_transition = null; }, }); // ---- 1. outgoing copy leaves first ---------------------------------- if (other_text.length) { tl.to( other_text, { opacity: 0, y: -8, duration: 0.22, ease: "power1.in", stagger: { each: 0.03, from: "end" }, }, 0 ); } // ---- 2. the morph ----------------------------------------------------- // NOTE: no absolute:true. It lifts the sides out of flow, collapsing the // wrapper to 0x0 — the parent then re-centres it and the nav, pinned at // left:-72px, slides ~460px to the right. tl.add( Flip.from(flip_state, { duration: 0.5, ease: "power2.out", nested: true, }), morph_at ); tl.to(other_overlay, { opacity: 1, duration: 0.28 }, morph_at); tl.to(other_media, { opacity: 0, duration: 0.28 }, morph_at); tl.to(current_overlay, { opacity: 0, duration: 0.3 }, morph_at + 0.16); tl.to(current_media, { opacity: 1, duration: 0.3 }, morph_at + 0.16); tl.to( backgrounds, { opacity: (index, target) => $(target).attr("video-color") === current_color ? 1 : 0, duration: 0.5, }, morph_at ); // ---- 3. incoming copy once the card is open --------------------------- tl.fromTo( current_text, { opacity: 0, y: 16 }, { opacity: (index, target) => Number($(target).attr("base_opacity")), y: 0, duration: 0.4, ease: "power2.out", stagger: 0.06, }, morph_at + 0.5 ); active_transition = tl; } } // --------------------------------------------------------------------------- // Shared // --------------------------------------------------------------------------- function is_reduced_motion() { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } // --------------------------------------------------------------------------- // Hero — a load sequence, not a scroll trigger // // The hero is above the fold on every viewport, so a ScrollTrigger would fire // on the same frame anyway and only adds a way for the copy to get stuck at // opacity 0 if anything upstream throws. // --------------------------------------------------------------------------- function hero_intro() { let hero = $(".section.fresh_platform-home_hero"); if (!hero.length) return; let eyebrow = hero.find(".flex.center-center.col-gap-10"); let paragraph = hero.find(".fresh-platform_hero-p"); let video_card = hero.find(".fresh-platform_hero-video-wrapper"); let buttons = hero.find(".button-wrap-combo"); // the pinwheel inside the "Introducing Fresh" pill. Scoped to the eyebrow // on purpose: .fresh-btn-logo.is-no-2 also lives in the hero, on the CTA // button, and that one already rides the buttons tween. let fresh_icon = eyebrow.find(".fresh-btn-logo"); if (is_reduced_motion()) { gsap.set([eyebrow, paragraph, buttons], { opacity: 1 }); gsap.set(fresh_icon, { opacity: 1, rotate: 0, scale: 1 }); return; } inject_motion_styles(); // scrubbed, and separate from the intro timeline: the card keeps settling // as the hero scrolls past build_card_grow(video_card, { scale: 1.08, start: "top bottom", end: "top 30%", }); // fonts change line boxes, so the intro waits for them when_fonts_ready(function () { let timeline = gsap.timeline({ defaults: { ease: "power4.out" } }); timeline .fromTo( eyebrow, { y: 16, opacity: 0 }, { y: 0, opacity: 1, duration: 0.7 } ) // Inside the timeline, not on its own clock: the pill is what carries // the logo on screen, so if the two ran separately a slow webfont // could let the spin finish behind a pill that is still invisible. .fromTo(fresh_icon, logo_from(), logo_to(), "-=0.5") .fromTo( paragraph, { y: 24, opacity: 0 }, { y: 0, opacity: 1, duration: 0.9 }, "-=0.45" ) .fromTo( buttons, { y: 24, opacity: 0 }, { y: 0, opacity: 1, duration: 0.8 }, "-=0.6" ) .fromTo(buttons.find(".fresh-btn-logo"), logo_from(), logo_to(), "<"); }); } // --------------------------------------------------------------------------- // Fresh pinwheel — the shared entrance // // One turn as it arrives, then it stops. Two numbers matter: // // from -180deg a half turn. The first attempt used -50deg, which on a // 24px image during page load is a nudge nobody catches. // Half a turn reads unmistakably as a spin without becoming // a loader. // power3.out decelerates into place, so it settles rather than stopping // dead. A linear finish on a shape this small reads as a // jitter. // // Function rather than a const so it is hoisted — hero_intro() and // fresh_logo_spin() both run before this line is reached. // --------------------------------------------------------------------------- function logo_from() { return { rotate: -180, scale: 0.8, opacity: 0, transformOrigin: "50% 50%" }; } function logo_to() { return { rotate: 0, scale: 1, opacity: 1, duration: 1.1, ease: "power3.out", overwrite: "auto", }; } // --------------------------------------------------------------------------- // Fresh pinwheel — the two logos outside the hero // // Four .fresh-btn-logo on the page; is-no-1 / is-no-2 is a size variant, not // an instance id, so each suffix appears twice. The hero pair arrive on the // intro timeline in hero_intro(). These two — the "New products" pill in the // Fresh lineup and the button in the closing CTA — sit in sections with no // reveal of their own, so they get the same entrance as they scroll in. // // once:true. It spins in and stops; there is no loop to pause, which is why // this no longer carries any viewport or tab-visibility bookkeeping. // --------------------------------------------------------------------------- function fresh_logo_spin() { let logos = $(".fresh-btn-logo"); if (!logos.length) return; if (is_reduced_motion()) { gsap.set(logos, { opacity: 1, rotate: 0, scale: 1 }); return; } logos.each(function () { let logo = $(this); if (logo.closest(".fresh_platform-home_hero").length) return; // hero_intro owns these gsap.fromTo( logo, logo_from(), Object.assign(logo_to(), { scrollTrigger: { trigger: this, start: "top 90%", once: true, invalidateOnRefresh: true, }, }) ); }); } // --------------------------------------------------------------------------- // Scroll reveals // // Selector-driven rather than attribute-driven, so no Webflow edits. Every // scrubbed entry is ease:"none" — a curve on a scrubbed tween fights the // scrub and rubber-bands. // // NO HEADINGS. Every h1-h4 on the page is left completely alone — no fade, // no lift, no split. Only supporting copy, cards, grids and media move. The // two "Better for staff" headings would have been excluded regardless: they // contain span.a-wrapper > .accent + .accent-mask, which already carries its // own ScrollTrigger sweep. // --------------------------------------------------------------------------- function scroll_reveals() { if (!window.ScrollTrigger) return; let reveal_config = [ // --- 03 partner logos: deliberately not animated. The row reads as one // block of proof, and staggering it turns a credibility cue into a // performance. // --- 04 webinar bar { selector: ".section.section-banner", type: "clip" }, // --- 05 better for staff (the "Better" heading is untouched) { selector: ".sub-heading.fz-30-regular", type: "fade", distance: 32, start: "top 70%", }, // --- 07 salesforce data { selector: ".home_platform-ai-card-wrapper", type: "converge", rotate: 5, lift: 90, push: 40, }, // --- 08 quote { selector: ".quote-card.is-fresh", type: "soft" }, { selector: ".quote-card.is-fresh .testimonial-author", type: "fade", distance: 20, delay: 0.2, }, // --- 09 fresh lineup { selector: ".fresh-platform_mfl-wrap", type: "converge", rotate: 6, lift: 110, offset: 0.06, }, { selector: ".fresh-platform_mfl-logo", type: "fade", distance: 12, delay: 0.35, }, // --- 10 effortless setup // triggered off the section, not the container: .fresh-platform_esfsi-container // is position:absolute and 1425px wide inside a 1393px section, so its own // box resolves start/end values that never match what you see. { selector: ".fresh-platform_esfsi-wrap", type: "stagger", distance: 32, stagger: 0.08, trigger: ".section_fresh-flatform-esfii", }, // --- 11 faq { selector: ".grid-1-col.row-gap-24.position-relative", type: "stagger", distance: 28, stagger: 0.07, }, // --- 12 cta + footer (heading untouched) // The footer is a global component shared with the rest of the site, so // nothing is targeted there from this page-level file. If you want the // "Amplifying the amazing good you do." line to reveal, add it to the // site-wide footer script instead. ]; if (is_reduced_motion()) return; inject_motion_styles(); when_fonts_ready(function () { reveal_config.forEach(function (config) { $(config.selector).each(function () { build_reveal($(this), config); }); }); // the showcase pin changes every start/end below it ScrollTrigger.refresh(); }); } function build_reveal(current_element, config) { let trigger_element = config.trigger ? document.querySelector(config.trigger) || current_element[0] : current_element[0]; let play_trigger = { trigger: trigger_element, start: config.start || "top 82%", once: true, toggleActions: "play none none none", invalidateOnRefresh: true, }; let scrub_trigger = { trigger: trigger_element, start: config.start || "top bottom", end: config.end || "top 40%", scrub: 1, invalidateOnRefresh: true, }; // ---- scrubbed -------------------------------------------------------- if (config.type === "grow") { build_card_grow(current_element, config); return; } if (config.type === "soft") { gsap.fromTo( current_element, { y: 16, scale: 0.96, opacity: 0, transformOrigin: "50% 60%", }, { y: 0, scale: 1, opacity: 1, ease: "none", immediateRender: true, scrollTrigger: scrub_trigger, } ); return; } if (config.type === "converge") { build_converge(current_element, config, scrub_trigger); return; } // ---- time based ------------------------------------------------------ if (config.type === "stagger") { let children = current_element.children(); if (!children.length) return; gsap.set(children, { y: config.distance || 40, opacity: 0 }); gsap.to(children, { y: 0, opacity: 1, duration: config.duration || 0.9, delay: config.delay || 0, stagger: config.stagger || 0.08, ease: "power4.out", overwrite: "auto", scrollTrigger: play_trigger, }); return; } if (config.type === "clip") { gsap.set(current_element, { y: 10, opacity: 0, clipPath: "inset(100% 0% 0% 0%)", }); gsap.to(current_element, { y: 0, opacity: 1, clipPath: "inset(0% 0% 0% 0%)", duration: config.duration || 1, delay: config.delay || 0, ease: "power4.out", scrollTrigger: play_trigger, }); return; } // default: fade up gsap.set(current_element, { y: config.distance || 40, opacity: 0 }); gsap.to(current_element, { y: 0, opacity: 1, duration: config.duration || 1, delay: config.delay || 0, ease: "power4.out", overwrite: "auto", scrollTrigger: play_trigger, }); } // scale down to 1 from a top-edge origin, so it settles downward instead of // shrinking in place. 1.08 and not 1.15: the hero card is 1149px wide and the // bigger value pushes its edges past .section-wrapper-card before it settles. function build_card_grow(current_element, config) { if (!current_element.length) return; gsap.fromTo( current_element, { scale: config.scale || 1.08, transformOrigin: config.origin || "50% 0%", }, { scale: 1, ease: "none", immediateRender: true, scrollTrigger: { trigger: current_element[0], start: config.start || "top bottom", end: config.end || "top 40%", scrub: 1, invalidateOnRefresh: true, }, } ); } // children arrive from their own side of the container, each rotated with // transform-origin anchored to its outer corner. Rotation and X are dropped // under 900px, where the layout is a single column and a tilt reads as a bug. function build_converge(current_element, config, scrub_trigger) { let children = current_element .children() .toArray() .filter(function (child) { let box = child.getBoundingClientRect(); return box.width > 0 && box.height > 0; // skips the 0x0 embeds in stop 07 }); if (!children.length) return; let is_narrow = window.matchMedia("(max-width: 900px)").matches; let angle = config.rotate || 7; let lift = config.lift || 90; let push = config.push || 40; let offset = config.offset || 0.06; let host_box = current_element[0].getBoundingClientRect(); let host_center = (host_box.left + host_box.right) / 2; let timeline = gsap.timeline({ scrollTrigger: scrub_trigger }); children.forEach(function (child, child_index) { let child_box = child.getBoundingClientRect(); let side = (child_box.left + child_box.right) / 2 < host_center ? -1 : 1; gsap.set(child, { transformOrigin: side < 0 ? "0% 0%" : "100% 0%" }); timeline.fromTo( child, { y: lift, x: is_narrow ? 0 : side * push, rotate: is_narrow ? 0 : -side * angle, opacity: 0, }, { y: 0, x: 0, rotate: 0, opacity: 1, duration: 1, ease: "none", immediateRender: true, }, child_index * offset ); }); } // --------------------------------------------------------------------------- // Buttons — label roll // // The text is cloned into two stacked copies inside an overflow:hidden band. // On hover the first slides to -165% and the second arrives from +165%. Past // 100% because the extra travel means both copies are at full speed while // crossing the visible band, so neither is caught mid-decelerate inside the // clip. // // The band wraps the label rather than clipping the button: the primary // button's .button-bg.is-md-2 gradient is 380x141 and deliberately overflows. // --------------------------------------------------------------------------- function button_label_roll() { if (is_reduced_motion()) return; inject_motion_styles(); $(".button-wrap-combo .button").each(function () { build_label_roll($(this)); }); function build_label_roll(current_button) { let label_source = current_button.find(".home-hero-btn-text").first(); if (!label_source.length) return; if (label_source.attr("roll_ready") === "true") return; label_source.attr("roll_ready", "true"); // a wrapped label makes the clip band unreadable — leave those static let line_height = parseFloat(label_source.css("line-height")) || 0; if (line_height && label_source.height() > line_height * 1.6) return; let roll_band = $(''); let roll_copy = label_source.clone(); roll_copy.addClass("btn-roll-copy").attr("aria-hidden", "true"); label_source.addClass("btn-roll-source"); label_source.after(roll_band); roll_band.append(label_source).append(roll_copy); current_button.addClass("has-roll"); } } // --------------------------------------------------------------------------- // Font timing // // Fonts change line boxes, which invalidates every ScrollTrigger start/end, // so reveals are built after they resolve. // --------------------------------------------------------------------------- function when_fonts_ready(callback) { if (document.fonts && document.fonts.ready) { document.fonts.ready.then(callback); } else { callback(); } } // --------------------------------------------------------------------------- // Injected styles — same pattern as inject_showcase_styles() // --------------------------------------------------------------------------- function inject_motion_styles() { if (document.getElementById("fresh-platform-motion-css")) return; let motion_styles = document.createElement("style"); motion_styles.id = "fresh-platform-motion-css"; motion_styles.textContent = ` /* label roll — two stacked copies inside a clipped band. -165% and not -100%: the extra travel keeps both copies at full speed while they cross the visible band, so neither is caught mid-decelerate. */ .button.has-roll{ position:relative; isolation:isolate; } .btn-roll-band{ position:relative; z-index:1; display:inline-block; overflow:hidden; padding:.16em 0; margin:-.16em 0; } .btn-roll-band .btn-roll-source, .btn-roll-band .btn-roll-copy{ display:block; transition:transform .45s cubic-bezier(.22,1,.36,1); } /* The copy is a clone, so it froze whatever colour the original had at clone time and then drifted from it. Both halves inherit instead, so the label tracks the button's colour — including anything an interaction sets on hover — and the two copies can never disagree mid-roll. */ .btn-roll-band, .btn-roll-band .btn-roll-source, .btn-roll-band .btn-roll-copy, .btn-roll-band .btn-roll-source *, .btn-roll-band .btn-roll-copy *{ color:inherit; } .btn-roll-band .btn-roll-copy{ position:absolute; inset:.16em 0; transform:translateY(165%); } .button.has-roll:hover .btn-roll-source{ transform:translateY(-165%); } .button.has-roll:hover .btn-roll-copy{ transform:translateY(0); } /* [btn-wrap-trigger] fills the WRAP, not the button. Hovering the right-hand button turns the whole .button-wrap-combo black; its own label then goes white against it, which is what Webflow's .button.is-transparent.is-fresh-platform-hero-btn-2:hover already does. The left-hand button keeps its own gradient and its own dark text, because it is not the one being hovered. Nothing needs to touch the buttons here — the wrap is the only thing that changes, and Webflow already ships transition:background-color .2s on .button-wrap-combo for exactly this. :has() rather than .button-wrap-combo:hover, so hovering the LEFT button does not fill the wrap. Specificity is (0,4,0) — the argument inside :has() counts — which clears .button-wrap-combo.is-fresh-platform-hero-ver at (0,2,0). */ .button-wrap-combo:has(.button[btn-wrap-trigger]:hover), .button-wrap-combo:has(.button[btn-wrap-trigger]:focus-visible){ background-color:var(--black); } @media (prefers-reduced-motion: reduce){ .btn-roll-band .btn-roll-source, .btn-roll-band .btn-roll-copy{ transition:none !important; } } `; document.head.appendChild(motion_styles); } // --------------------------------------------------------------------------- // Fresh lineup cards // // The videos just play, at every width. There is no hover reveal any more — // the thumbnail overlay is hidden on load and the clip runs behind it, which // is what the below-992 branch always did. Desktop now matches it. // // Webflow already autoplays these (data-autoplay="true"), so this mostly has // to stop hiding them: drop the overlay and make sure play() actually took. // --------------------------------------------------------------------------- function mfl_card_video() { $(".platform-mfl_card").each(function () { let current_card = $(this); let current_overlay = current_card.find(".platform-mfl_card-top-overlay"); let current_video = current_card.find("video"); // the third card is a static SVG, not a video — nothing behind the image if (!current_overlay.length || !current_video.length) return; gsap.killTweensOf(current_overlay); current_overlay.css("opacity", 0); play_mfl_video(current_video); }); // Chrome pauses video-only background media while it is off-screen and // rejects play() with an AbortError when it does — and this section starts // far down the page, so the first call is refused nearly every time. Wait // for the frame that proves it can decode, then ask once more. function play_mfl_video(current_video) { current_video.each(function () { let video_element = this; if (!video_element.play) return; let played = video_element.play(); if (!played || !played.catch) return; played.catch(function () { if (video_element.readyState >= 3) return; // buffered; a real refusal video_element.addEventListener("canplay", function play_once() { video_element.removeEventListener("canplay", play_once); let retried = video_element.play(); if (retried && retried.catch) retried.catch(() => {}); }); }); }); } } });