From 698abb2e821ebc98d7b63da675ae5a9fe6356bc5 Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Mon, 10 Aug 2026 03:41:37 -0700 Subject: [PATCH] Merge nucleic/warm-velvet-dingo-rmhb into main Mirrored from corpo 464f4e7. --- src/pages/index.astro | 150 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 141 insertions(+), 9 deletions(-) diff --git a/src/pages/index.astro b/src/pages/index.astro index 4f59b3f..f7e7f89 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -518,9 +518,9 @@ const canonical = new URL('/', Astro.site); .has-scroll-stack .hero-copy { position: sticky; - top: 50%; + top: var(--hero-copy-pin-top, 0px); align-self: flex-start; - transform: translateY(-50%); + transform: none; } /* Enlarged type and middle-width windows can make the flex line wrap before the @@ -694,8 +694,8 @@ const canonical = new URL('/', Astro.site); .is-scroll-stack .technology-stage { position: sticky; - top: 50%; - transform: translateY(-50%); + top: var(--technology-stage-pin-top, 0px); + transform: none; } .technology-panel { @@ -907,6 +907,19 @@ const canonical = new URL('/', Astro.site); --edge: clamp(1.15rem, 5vw, 1.85rem); } + /* The first gesture clears the hero and settles on Nucleic by itself. The two + technology rows then become the remaining stops, so even a quick mobile fling + cannot skip straight from the opening composition to the second product. */ + main.has-technology-scroll { + scroll-snap-type: y mandatory; + } + + .has-technology-scroll .hero-copy, + .has-technology-scroll .technology-steps > span { + scroll-snap-align: center; + scroll-snap-stop: always; + } + .hero { padding-top: clamp(2.5rem, 8vh, 5rem); } @@ -1010,6 +1023,22 @@ const canonical = new URL('/', Astro.site); } } + /* Keep the desktop composition on one flex line through the narrow-desktop range. + Without this optical scale and permission for the card to use its actual four + columns, their minimum widths add up to just over the available measure at + roughly 721–999px. The flex line then wraps and enters its stacked fallback, + where the copy naturally leaves before the card story finishes. The curve meets + the phone size at 721px and approaches the normal desktop size at 1000px. */ + @media (min-width: 721px) and (max-width: 999px) { + .statement { + font-size: clamp(3.2rem, calc(12.5vw - 2.4rem), 5.45rem); + } + + .technology-stack { + min-width: 0; + } + } + @media (prefers-reduced-motion: reduce) { *, *::before, @@ -1044,18 +1073,93 @@ const canonical = new URL('/', Astro.site); const hero = heroLead?.closest('.hero'); const heroCopy = heroLead?.querySelector('.hero-copy'); const scrollRoot = technologyStack.closest('main'); + const technologyStage = technologyStack.querySelector('.technology-stage'); const cards = [...technologyStack.querySelectorAll('[data-technology-card]')]; const steps = [...technologyStack.querySelectorAll('[data-technology-step]')]; const progressCurrent = technologyStack.querySelector('[data-technology-progress-current]'); + const mobileStack = window.matchMedia('(max-width: 720px)'); heroLead?.classList.add('has-scroll-stack'); hero?.classList.add('has-scroll-story'); - if (heroLead && heroCopy) { - const stackWrappedBelow = technologyStack.offsetLeft <= heroCopy.offsetLeft + 1; - heroLead.classList.toggle('is-stacked-stack', stackWrappedBelow); - } + scrollRoot?.classList.add('has-technology-scroll'); technologyStack.classList.add('is-scroll-stack'); + let stackWrappedBelow = false; + let mobileEntryScrollTop = 0; + let mobileStackEntered = false; + + const syncPinnedInsets = () => { + if (!scrollRoot || !heroCopy || !technologyStage || stackWrappedBelow) return; + + const setCenteredInset = (element: HTMLElement, property: string) => { + const inset = `${Math.max(0, (scrollRoot.clientHeight - element.offsetHeight) / 2)}px`; + if (element.style.getPropertyValue(property) !== inset) { + element.style.setProperty(property, inset); + } + }; + + setCenteredInset(heroCopy, '--hero-copy-pin-top'); + setCenteredInset(technologyStage, '--technology-stage-pin-top'); + }; + + const syncMobileEntry = () => { + if (!mobileStack.matches || !scrollRoot || !heroCopy || !technologyStage) return; + + const rootRect = scrollRoot.getBoundingClientRect(); + const copyRect = heroCopy.getBoundingClientRect(); + const stackRect = technologyStack.getBoundingClientRect(); + const stageInset = Number.parseFloat(getComputedStyle(technologyStage).top) || 0; + const currentScroll = scrollRoot.scrollTop; + + /* These resolve to document-space thresholds even while the stage is sticky. + The handoff waits for both conditions: the hero is above the scrollport and + the product card has reached its pinned position. */ + const heroCleared = currentScroll + copyRect.bottom - rootRect.top + 16; + const stagePinned = currentScroll + stackRect.top - rootRect.top - stageInset; + mobileEntryScrollTop = Math.max(0, Math.ceil(heroCleared), Math.ceil(stagePinned)); + }; + + const syncEnhancedLayout = () => { + if (!heroLead || !heroCopy) return; + + /* Remove the fallback before measuring so resizing from a stacked viewport + back to two columns can recover instead of measuring its own forced block. */ + heroLead.classList.remove('is-stacked-stack'); + stackWrappedBelow = technologyStack.offsetLeft <= heroCopy.offsetLeft + 1; + heroLead.classList.toggle('is-stacked-stack', stackWrappedBelow); + + if (stackWrappedBelow) { + heroCopy.style.removeProperty('--hero-copy-pin-top'); + technologyStage?.style.removeProperty('--technology-stage-pin-top'); + } else { + syncPinnedInsets(); + } + + syncMobileEntry(); + }; + + syncEnhancedLayout(); + + if ('ResizeObserver' in window && scrollRoot && heroCopy && technologyStage) { + const pinObserver = new ResizeObserver(() => { + syncPinnedInsets(); + syncMobileEntry(); + }); + pinObserver.observe(scrollRoot); + pinObserver.observe(heroCopy); + pinObserver.observe(technologyStage); + } + + let resizeFrame = 0; + window.addEventListener( + 'resize', + () => { + cancelAnimationFrame(resizeFrame); + resizeFrame = requestAnimationFrame(syncEnhancedLayout); + }, + { passive: true }, + ); + const activateTechnology = (index: number) => { cards.forEach((card, cardIndex) => { const isActive = cardIndex === index; @@ -1069,12 +1173,40 @@ const canonical = new URL('/', Astro.site); activateTechnology(0); + let mobileEntryTimer = 0; + const settleMobileEntry = () => { + if (!mobileStack.matches || !scrollRoot || mobileStackEntered) return; + if (scrollRoot.scrollTop + 1 < mobileEntryScrollTop) return; + + /* A single fast fling may cross every observer step. Return it to the required + Nucleic-only frame; only a later gesture is allowed to advance the stack. */ + mobileStackEntered = true; + activateTechnology(0); + scrollRoot.scrollTo({ top: mobileEntryScrollTop, behavior: 'auto' }); + }; + + scrollRoot?.addEventListener( + 'scroll', + () => { + if (mobileStack.matches && scrollRoot.scrollTop <= mobileEntryScrollTop * 0.25) { + mobileStackEntered = false; + } + + window.clearTimeout(mobileEntryTimer); + mobileEntryTimer = window.setTimeout(settleMobileEntry, 120); + }, + { passive: true }, + ); + + scrollRoot?.addEventListener('scrollend', settleMobileEntry, { passive: true }); + const stepObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; const index = Number((entry.target as HTMLElement).dataset.technologyStep); - if (Number.isInteger(index)) activateTechnology(index); + const canActivate = index === 0 || !mobileStack.matches || mobileStackEntered; + if (Number.isInteger(index) && canActivate) activateTechnology(index); }); }, { root: scrollRoot, rootMargin: '-45% 0px -45% 0px' },