Merge nucleic/warm-velvet-dingo-rmhb into main

Mirrored from corpo 464f4e7.
This commit is contained in:
2026-08-10 03:41:37 -07:00
parent b7249030f4
commit 698abb2e82
+141 -9
View File
@@ -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<HTMLElement>('.hero');
const heroCopy = heroLead?.querySelector<HTMLElement>('.hero-copy');
const scrollRoot = technologyStack.closest<HTMLElement>('main');
const technologyStage = technologyStack.querySelector<HTMLElement>('.technology-stage');
const cards = [...technologyStack.querySelectorAll<HTMLAnchorElement>('[data-technology-card]')];
const steps = [...technologyStack.querySelectorAll<HTMLElement>('[data-technology-step]')];
const progressCurrent = technologyStack.querySelector<HTMLElement>('[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' },