Merge nucleic/lucid-willow-panda-uctw into main
Mirrored from corpo c00d47b.
This commit is contained in:
+101
-79
@@ -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<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)');
|
||||
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' },
|
||||
|
||||
Reference in New Issue
Block a user