diff --git a/src/pages/index.astro b/src/pages/index.astro index f7e7f89..2c2f431 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -505,7 +505,7 @@ const canonical = new URL('/', Astro.site); .hero-lead.has-scroll-stack { position: relative; - min-height: clamp(68rem, 185svh, 108rem); + min-height: clamp(54rem, 145svh, 84rem); align-items: stretch; } @@ -541,7 +541,7 @@ const canonical = new URL('/', Astro.site); .is-stacked-stack .technology-stack { width: min(100%, 28rem); min-width: 0; - min-height: 135svh; + min-height: 100svh; margin-top: clamp(3.25rem, 9vh, 6rem); margin-left: auto; } @@ -907,21 +907,10 @@ 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); + --mobile-hero-pin-top: clamp(2.5rem, 8vh, 5rem); + + padding-top: var(--mobile-hero-pin-top); } .statement { @@ -937,11 +926,11 @@ const canonical = new URL('/', Astro.site); } .hero-lead.has-scroll-stack { + height: max-content; min-height: 0; } - .hero-copy, - .has-scroll-stack .hero-copy { + .hero-copy { position: relative; top: auto; display: grid; @@ -949,6 +938,22 @@ const canonical = new URL('/', Astro.site); transform: none; } + /* Pinned at exactly the offset it already occupies, so the copy is held from the + first pixel of scroll rather than travelling to meet a pin line. */ + .has-scroll-stack .hero-copy { + position: sticky; + z-index: 2; + top: var(--mobile-hero-pin-top); + } + + /* Viewports too short to hold the copy and the card at once are the one case the + composition cannot be static: the copy is released to scroll away, and the card + pins at the CSS inset below instead of at its own resting offset. */ + .has-scroll-stack.is-hero-released .hero-copy { + position: relative; + top: auto; + } + .mission { max-width: 25rem; } @@ -959,17 +964,24 @@ const canonical = new URL('/', Astro.site); margin-top: clamp(3.25rem, 9vh, 6rem); } + /* The lead is what the script tightens when the card would otherwise sit too low + to be pinned where it rests; the clamp is the value it starts from. */ .hero-lead.is-stacked-stack .technology-stack { width: 100%; + margin-top: var(--technology-stack-lead, clamp(3.25rem, 9vh, 6rem)); margin-left: 0; } + /* Scroll length for the story, and nothing else — the card no longer spends any + of it travelling, so a full viewport of it would read as a dead stretch. */ .has-scroll-stack .technology-stack { - min-height: 135svh; + min-height: 70svh; } + /* The script pins this at the card's own resting offset. The clamp is the fallback + for the released case above, where the card does have to come up the screen. */ .is-scroll-stack .technology-stage { - top: clamp(8.5rem, 28%, 14rem); + top: var(--technology-stage-pin-top, clamp(8.5rem, 28%, 14rem)); transform: none; } @@ -977,6 +989,14 @@ const canonical = new URL('/', Astro.site); min-height: 11.9rem; } + /* Keep Nucleic alone for roughly the first half of the scroll, then move directly + into the next product without the old empty stretch between story beats. The + split is below half because the step boxes are read against a band at the middle + of the viewport, which the stack's own top edge starts well below. */ + .is-scroll-stack .technology-steps { + grid-template-rows: 23% 77%; + } + /* One line each: side by side these two need ~360px of tracking-heavy mono, which is more than a 390px screen has once the edge inset is paid. */ .footer { @@ -1018,8 +1038,10 @@ const canonical = new URL('/', Astro.site); so tighten the spacing but leave the type alone and let the page scroll. */ @media (max-height: 700px) and (max-width: 720px) { .hero { + --mobile-hero-pin-top: clamp(0.75rem, 2.2vh, 1.75rem); + row-gap: clamp(1.1rem, 2.9vh, 2rem); - padding: clamp(0.75rem, 2.2vh, 1.75rem) 0 clamp(0.5rem, 1.4vh, 1.25rem); + padding: var(--mobile-hero-pin-top) 0 clamp(0.5rem, 1.4vh, 1.25rem); } } @@ -1077,19 +1099,17 @@ const canonical = new URL('/', Astro.site); 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)'); + const mobileLayout = window.matchMedia('(max-width: 720px)'); heroLead?.classList.add('has-scroll-stack'); hero?.classList.add('has-scroll-story'); - scrollRoot?.classList.add('has-technology-scroll'); technologyStack.classList.add('is-scroll-stack'); let stackWrappedBelow = false; - let mobileEntryScrollTop = 0; - let mobileStackEntered = false; + /* Two columns: copy and card each hold the middle of their own half of the line. */ const syncPinnedInsets = () => { - if (!scrollRoot || !heroCopy || !technologyStage || stackWrappedBelow) return; + if (!scrollRoot || !heroCopy || !technologyStage) return; const setCenteredInset = (element: HTMLElement, property: string) => { const inset = `${Math.max(0, (scrollRoot.clientHeight - element.offsetHeight) / 2)}px`; @@ -1102,21 +1122,58 @@ const canonical = new URL('/', Astro.site); setCenteredInset(technologyStage, '--technology-stage-pin-top'); }; - const syncMobileEntry = () => { - if (!mobileStack.matches || !scrollRoot || !heroCopy || !technologyStage) return; + /* One column, on a phone: the card is pinned at the offset it already occupies, + so the first pixel of scroll advances the story instead of dragging the card up + to a pin line above it and only then starting. Where the card would rest too low + to be fully visible, the lead above it is tightened rather than letting it + travel; only when even a minimal lead cannot fit is the copy released. */ + const MINIMUM_STACK_LEAD = 20; - const rootRect = scrollRoot.getBoundingClientRect(); - const copyRect = heroCopy.getBoundingClientRect(); - const stackRect = technologyStack.getBoundingClientRect(); - const stageInset = Number.parseFloat(getComputedStyle(technologyStage).top) || 0; - const currentScroll = scrollRoot.scrollTop; + const syncStaticStory = () => { + if (!hero || !heroLead || !heroCopy || !scrollRoot || !technologyStage) return; - /* 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)); + /* Measure against the CSS lead, not a lead this function set on an earlier pass, + so shrinking and re-widening a window recovers the full spacing. */ + technologyStack.style.removeProperty('--technology-stack-lead'); + technologyStage.style.removeProperty('--technology-stage-pin-top'); + + const heroStyle = getComputedStyle(hero); + const copyBottom = + (Number.parseFloat(heroStyle.paddingTop) || 0) + heroCopy.offsetHeight; + /* The card has to clear the hero's own bottom padding, or the sticky element + runs out of containing block before the scroll ends and drifts at the tail. */ + const gutter = (Number.parseFloat(heroStyle.paddingBottom) || 0) + 8; + const lead = Math.min( + Number.parseFloat(getComputedStyle(technologyStack).marginTop) || 0, + scrollRoot.clientHeight - gutter - technologyStage.offsetHeight - copyBottom, + ); + + const released = lead < MINIMUM_STACK_LEAD; + heroLead.classList.toggle('is-hero-released', released); + if (released) return; + + /* The pin is derived from the lead that was actually applied, not from the + unrounded one: half a pixel of disagreement is half a pixel of travel. */ + const appliedLead = Math.round(lead); + technologyStack.style.setProperty('--technology-stack-lead', `${appliedLead}px`); + technologyStage.style.setProperty( + '--technology-stage-pin-top', + `${copyBottom + appliedLead}px`, + ); + }; + + const clearStaticStory = () => { + heroLead?.classList.remove('is-hero-released'); + technologyStack.style.removeProperty('--technology-stack-lead'); + technologyStage?.style.removeProperty('--technology-stage-pin-top'); + }; + + const syncInsets = () => { + if (!stackWrappedBelow) { + syncPinnedInsets(); + } else if (mobileLayout.matches) { + syncStaticStory(); + } }; const syncEnhancedLayout = () => { @@ -1125,26 +1182,19 @@ const canonical = new URL('/', Astro.site); /* 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'); + clearStaticStory(); 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(); - } + if (stackWrappedBelow) heroCopy.style.removeProperty('--hero-copy-pin-top'); - syncMobileEntry(); + syncInsets(); }; syncEnhancedLayout(); if ('ResizeObserver' in window && scrollRoot && heroCopy && technologyStage) { - const pinObserver = new ResizeObserver(() => { - syncPinnedInsets(); - syncMobileEntry(); - }); + const pinObserver = new ResizeObserver(syncInsets); pinObserver.observe(scrollRoot); pinObserver.observe(heroCopy); pinObserver.observe(technologyStage); @@ -1173,40 +1223,12 @@ 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); - const canActivate = index === 0 || !mobileStack.matches || mobileStackEntered; - if (Number.isInteger(index) && canActivate) activateTechnology(index); + if (Number.isInteger(index)) activateTechnology(index); }); }, { root: scrollRoot, rootMargin: '-45% 0px -45% 0px' },