Merge nucleic/lucid-willow-panda-uctw into main

Mirrored from corpo c00d47b.
This commit is contained in:
2026-08-10 18:50:42 -07:00
parent 698abb2e82
commit 62e5a428c8
+101 -79
View File
@@ -505,7 +505,7 @@ const canonical = new URL('/', Astro.site);
.hero-lead.has-scroll-stack { .hero-lead.has-scroll-stack {
position: relative; position: relative;
min-height: clamp(68rem, 185svh, 108rem); min-height: clamp(54rem, 145svh, 84rem);
align-items: stretch; align-items: stretch;
} }
@@ -541,7 +541,7 @@ const canonical = new URL('/', Astro.site);
.is-stacked-stack .technology-stack { .is-stacked-stack .technology-stack {
width: min(100%, 28rem); width: min(100%, 28rem);
min-width: 0; min-width: 0;
min-height: 135svh; min-height: 100svh;
margin-top: clamp(3.25rem, 9vh, 6rem); margin-top: clamp(3.25rem, 9vh, 6rem);
margin-left: auto; margin-left: auto;
} }
@@ -907,21 +907,10 @@ const canonical = new URL('/', Astro.site);
--edge: clamp(1.15rem, 5vw, 1.85rem); --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 { .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 { .statement {
@@ -937,11 +926,11 @@ const canonical = new URL('/', Astro.site);
} }
.hero-lead.has-scroll-stack { .hero-lead.has-scroll-stack {
height: max-content;
min-height: 0; min-height: 0;
} }
.hero-copy, .hero-copy {
.has-scroll-stack .hero-copy {
position: relative; position: relative;
top: auto; top: auto;
display: grid; display: grid;
@@ -949,6 +938,22 @@ const canonical = new URL('/', Astro.site);
transform: none; 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 { .mission {
max-width: 25rem; max-width: 25rem;
} }
@@ -959,17 +964,24 @@ const canonical = new URL('/', Astro.site);
margin-top: clamp(3.25rem, 9vh, 6rem); 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 { .hero-lead.is-stacked-stack .technology-stack {
width: 100%; width: 100%;
margin-top: var(--technology-stack-lead, clamp(3.25rem, 9vh, 6rem));
margin-left: 0; 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 { .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 { .is-scroll-stack .technology-stage {
top: clamp(8.5rem, 28%, 14rem); top: var(--technology-stage-pin-top, clamp(8.5rem, 28%, 14rem));
transform: none; transform: none;
} }
@@ -977,6 +989,14 @@ const canonical = new URL('/', Astro.site);
min-height: 11.9rem; 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 /* 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. */ is more than a 390px screen has once the edge inset is paid. */
.footer { .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. */ so tighten the spacing but leave the type alone and let the page scroll. */
@media (max-height: 700px) and (max-width: 720px) { @media (max-height: 700px) and (max-width: 720px) {
.hero { .hero {
--mobile-hero-pin-top: clamp(0.75rem, 2.2vh, 1.75rem);
row-gap: clamp(1.1rem, 2.9vh, 2rem); 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 cards = [...technologyStack.querySelectorAll<HTMLAnchorElement>('[data-technology-card]')];
const steps = [...technologyStack.querySelectorAll<HTMLElement>('[data-technology-step]')]; const steps = [...technologyStack.querySelectorAll<HTMLElement>('[data-technology-step]')];
const progressCurrent = technologyStack.querySelector<HTMLElement>('[data-technology-progress-current]'); 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'); heroLead?.classList.add('has-scroll-stack');
hero?.classList.add('has-scroll-story'); hero?.classList.add('has-scroll-story');
scrollRoot?.classList.add('has-technology-scroll');
technologyStack.classList.add('is-scroll-stack'); technologyStack.classList.add('is-scroll-stack');
let stackWrappedBelow = false; 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 = () => { const syncPinnedInsets = () => {
if (!scrollRoot || !heroCopy || !technologyStage || stackWrappedBelow) return; if (!scrollRoot || !heroCopy || !technologyStage) return;
const setCenteredInset = (element: HTMLElement, property: string) => { const setCenteredInset = (element: HTMLElement, property: string) => {
const inset = `${Math.max(0, (scrollRoot.clientHeight - element.offsetHeight) / 2)}px`; 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'); setCenteredInset(technologyStage, '--technology-stage-pin-top');
}; };
const syncMobileEntry = () => { /* One column, on a phone: the card is pinned at the offset it already occupies,
if (!mobileStack.matches || !scrollRoot || !heroCopy || !technologyStage) return; 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 syncStaticStory = () => {
const copyRect = heroCopy.getBoundingClientRect(); if (!hero || !heroLead || !heroCopy || !scrollRoot || !technologyStage) return;
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. /* Measure against the CSS lead, not a lead this function set on an earlier pass,
The handoff waits for both conditions: the hero is above the scrollport and so shrinking and re-widening a window recovers the full spacing. */
the product card has reached its pinned position. */ technologyStack.style.removeProperty('--technology-stack-lead');
const heroCleared = currentScroll + copyRect.bottom - rootRect.top + 16; technologyStage.style.removeProperty('--technology-stage-pin-top');
const stagePinned = currentScroll + stackRect.top - rootRect.top - stageInset;
mobileEntryScrollTop = Math.max(0, Math.ceil(heroCleared), Math.ceil(stagePinned)); 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 = () => { const syncEnhancedLayout = () => {
@@ -1125,26 +1182,19 @@ const canonical = new URL('/', Astro.site);
/* Remove the fallback before measuring so resizing from a stacked viewport /* Remove the fallback before measuring so resizing from a stacked viewport
back to two columns can recover instead of measuring its own forced block. */ back to two columns can recover instead of measuring its own forced block. */
heroLead.classList.remove('is-stacked-stack'); heroLead.classList.remove('is-stacked-stack');
clearStaticStory();
stackWrappedBelow = technologyStack.offsetLeft <= heroCopy.offsetLeft + 1; stackWrappedBelow = technologyStack.offsetLeft <= heroCopy.offsetLeft + 1;
heroLead.classList.toggle('is-stacked-stack', stackWrappedBelow); heroLead.classList.toggle('is-stacked-stack', stackWrappedBelow);
if (stackWrappedBelow) { if (stackWrappedBelow) heroCopy.style.removeProperty('--hero-copy-pin-top');
heroCopy.style.removeProperty('--hero-copy-pin-top');
technologyStage?.style.removeProperty('--technology-stage-pin-top');
} else {
syncPinnedInsets();
}
syncMobileEntry(); syncInsets();
}; };
syncEnhancedLayout(); syncEnhancedLayout();
if ('ResizeObserver' in window && scrollRoot && heroCopy && technologyStage) { if ('ResizeObserver' in window && scrollRoot && heroCopy && technologyStage) {
const pinObserver = new ResizeObserver(() => { const pinObserver = new ResizeObserver(syncInsets);
syncPinnedInsets();
syncMobileEntry();
});
pinObserver.observe(scrollRoot); pinObserver.observe(scrollRoot);
pinObserver.observe(heroCopy); pinObserver.observe(heroCopy);
pinObserver.observe(technologyStage); pinObserver.observe(technologyStage);
@@ -1173,40 +1223,12 @@ const canonical = new URL('/', Astro.site);
activateTechnology(0); 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( const stepObserver = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (!entry.isIntersecting) return; if (!entry.isIntersecting) return;
const index = Number((entry.target as HTMLElement).dataset.technologyStep); const index = Number((entry.target as HTMLElement).dataset.technologyStep);
const canActivate = index === 0 || !mobileStack.matches || mobileStackEntered; if (Number.isInteger(index)) activateTechnology(index);
if (Number.isInteger(index) && canActivate) activateTechnology(index);
}); });
}, },
{ root: scrollRoot, rootMargin: '-45% 0px -45% 0px' }, { root: scrollRoot, rootMargin: '-45% 0px -45% 0px' },