Merge nucleic/calm-willow-ibis-dozm into main

Mirrored from corpo dad941a.
This commit is contained in:
2026-08-11 18:24:24 -07:00
parent cfb485460c
commit 1d36fe6ff6
+345 -30
View File
@@ -141,10 +141,24 @@ const canonical = new URL('/', Astro.site);
</a> </a>
</div> </div>
<div class="technology-index" role="status" aria-live="polite" aria-atomic="true"> <!-- The scrollbar is hidden by design, so nothing on the page says
<span class="technology-index-current" data-technology-progress-current>01</span> there is more of it. This does: a segment per product, the
<span aria-hidden="true">/</span> current one filling as its beat is scrolled through. The
<span>03</span> segments are built by the enhancement from the cards
themselves, so a product added to the stack cannot leave a
stale one behind. -->
<div class="technology-progress">
<div class="technology-track" data-technology-track aria-hidden="true"></div>
<p class="technology-hint" data-technology-hint>
Scroll <span class="technology-hint-arrow" aria-hidden="true">↓</span>
</p>
<div class="technology-index" role="status" aria-live="polite" aria-atomic="true">
<span class="technology-index-current" data-technology-progress-current>01</span>
<span aria-hidden="true">/</span>
<span>03</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -457,6 +471,7 @@ const canonical = new URL('/', Astro.site);
.card-index, .card-index,
.technology-index, .technology-index,
.technology-hint,
.footer { .footer {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem; font-size: 0.78rem;
@@ -527,10 +542,13 @@ const canonical = new URL('/', Astro.site);
/* Scroll length is per story beat, not a fixed page height: a card added to the /* Scroll length is per story beat, not a fixed page height: a card added to the
stack has to lengthen this or every beat gets shorter. Sized so three beats each stack has to lengthen this or every beat gets shorter. Sized so three beats each
hold the stage for about as long as the two-card stack gave one. */ hold the stage for about as long as the two-card stack gave one. The upper bound
is what a tall window actually runs into — at 1400px tall the old one left the
whole story about 490px of scroll, a third of it for the last product. It has to
clear the tallest viewport by enough to still be a scroll. */
.hero-lead.has-scroll-stack { .hero-lead.has-scroll-stack {
position: relative; position: relative;
min-height: clamp(66rem, 178svh, 103rem); min-height: clamp(66rem, 178svh, 140rem);
align-items: stretch; align-items: stretch;
} }
@@ -810,12 +828,90 @@ const canonical = new URL('/', Astro.site);
line-height: 1.45; line-height: 1.45;
} }
/* Only the scrolling story has progress to report; the static list below the
enhancement's own conditions has none, and says nothing. */
.technology-progress {
display: none;
grid-template-columns: 1fr auto;
align-items: center;
margin-top: 1.05rem;
row-gap: 0.6rem;
column-gap: 1rem;
}
.is-scroll-stack .technology-progress {
display: grid;
}
.technology-track {
display: grid;
grid-column: 1 / -1;
grid-auto-flow: column;
/* Equal shares on purpose, unlike the step boxes they measure: these say which
product of how many, not how much scroll each one happens to cost. */
grid-auto-columns: 1fr;
gap: 0.4rem;
}
.technology-track span {
position: relative;
height: 2px;
/* Same hairline as every rule on the page, so the empty track reads as part of
the composition rather than as a control sitting on top of it. */
background: var(--line);
}
/* The fill is a scaled bar, not a width: it is written on every scroll frame, and
transform is the one property that costs neither layout nor paint to change. */
.technology-track span::after {
position: absolute;
inset: 0;
background: var(--pine);
content: '';
transform: scaleX(var(--beat-fill, 0));
transform-origin: left;
}
.technology-hint {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0;
color: var(--muted);
transition: opacity 500ms ease;
}
/* Said once. The first scroll is proof it has been read, and it does not come back
— an instruction that outlives its own need is the obtrusive kind. */
.technology-hint.is-read {
opacity: 0;
}
/* A long hold, then a single fall: a continuous bob reads as decoration, where one
nudge every few seconds reads as the page pointing somewhere. Scoped to the
unread hint so the loop ends with the thing it was pointing at — an invisible
element still animating is a frame's work every few seconds forever. */
.technology-hint:not(.is-read) .technology-hint-arrow {
animation: hint-fall 3.6s 1.4s ease-in-out infinite;
}
@keyframes hint-fall {
0%,
72%,
100% {
transform: translateY(0);
}
84% {
transform: translateY(0.3em);
}
}
.technology-index { .technology-index {
display: none; display: none;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 0.45rem; gap: 0.45rem;
margin-top: 0.75rem;
color: var(--muted); color: var(--muted);
} }
@@ -838,11 +934,14 @@ const canonical = new URL('/', Astro.site);
middle of the viewport, and the stack's own top edge starts well below it, so middle of the viewport, and the stack's own top edge starts well below it, so
the opening box has to be the longest simply to reach that band — split evenly, the opening box has to be the longest simply to reach that band — split evenly,
the first product would flick past in a third of the scroll the middle one gets. the first product would flick past in a third of the scroll the middle one gets.
Measured against the rendered page: these hold each product for within ~10% of The closing box is the other way round: it is the last thing before the page
the same distance from 700px to 1100px tall. */ runs out, so what it holds is whatever scroll is left after it starts, and
giving it a bigger share only takes that away. Measured against the rendered
page from 720px to 1400px tall, at ordinary and enlarged text: these hold each
product for within ~30% of the same distance, most sizes within ~10%. */
.is-scroll-stack .technology-steps { .is-scroll-stack .technology-steps {
display: grid; display: grid;
grid-template-rows: 44% 19% 37%; grid-template-rows: 40% 18% 42%;
} }
/* The wrapped fallback pins its card near the top of the viewport rather than at /* The wrapped fallback pins its card near the top of the viewport rather than at
@@ -1015,11 +1114,11 @@ const canonical = new URL('/', Astro.site);
} }
/* Scroll length for the story, and nothing else — the card no longer spends any /* 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. Holds of it travelling, so a full viewport of it would read as a dead stretch. Three
the phone's pace from the two-card stack across three beats rather than making beats need more of it than two did: at 90svh the last product held the stage
the story a third longer again. */ for under 60px, which is inside a single flick. */
.has-scroll-stack .technology-stack { .has-scroll-stack .technology-stack {
min-height: 90svh; min-height: 110svh;
} }
/* The script pins this at the card's own resting offset. The clamp is the fallback /* The script pins this at the card's own resting offset. The clamp is the fallback
@@ -1033,12 +1132,13 @@ const canonical = new URL('/', Astro.site);
min-height: 11.9rem; min-height: 11.9rem;
} }
/* The same unequal split as the desktop stack, retuned: a phone's step boxes are /* The same reasoning as the desktop stack, landing somewhere else: a phone's
read against the same centre band, but the stack starts much further down its stack begins so far down its own scroll that the opening box has nearly
own scroll, so the opening share is larger again. Measured across 640–932px reached the band before the story starts, so here it wants the smallest share
tall, these keep the three products within ~25% of one another. */ rather than the largest. Measured across 640–932px tall, at ordinary and
enlarged text, these keep the three products within ~65% of one another. */
.is-scroll-stack .technology-steps { .is-scroll-stack .technology-steps {
grid-template-rows: 29% 19% 52%; grid-template-rows: 13% 25% 62%;
} }
/* 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
@@ -1144,6 +1244,8 @@ 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 progressTrack = technologyStack.querySelector<HTMLElement>('[data-technology-track]');
const progressHint = technologyStack.querySelector<HTMLElement>('[data-technology-hint]');
const mobileLayout = window.matchMedia('(max-width: 720px)'); const mobileLayout = window.matchMedia('(max-width: 720px)');
heroLead?.classList.add('has-scroll-stack'); heroLead?.classList.add('has-scroll-stack');
@@ -1241,8 +1343,15 @@ const canonical = new URL('/', Astro.site);
syncEnhancedLayout(); syncEnhancedLayout();
/* Anything that moves the pins moves the offsets the keyboard and the progress
track are measured against, so the two re-read together. Both of these run
after this script has finished evaluating, which is the only reason they can
name a function declared further down it. */
if ('ResizeObserver' in window && scrollRoot && heroCopy && technologyStage) { if ('ResizeObserver' in window && scrollRoot && heroCopy && technologyStage) {
const pinObserver = new ResizeObserver(syncInsets); const pinObserver = new ResizeObserver(() => {
syncInsets();
measureBeats();
});
pinObserver.observe(scrollRoot); pinObserver.observe(scrollRoot);
pinObserver.observe(heroCopy); pinObserver.observe(heroCopy);
pinObserver.observe(technologyStage); pinObserver.observe(technologyStage);
@@ -1253,7 +1362,10 @@ const canonical = new URL('/', Astro.site);
'resize', 'resize',
() => { () => {
cancelAnimationFrame(resizeFrame); cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(syncEnhancedLayout); resizeFrame = requestAnimationFrame(() => {
syncEnhancedLayout();
measureBeats();
});
}, },
{ passive: true }, { passive: true },
); );
@@ -1273,7 +1385,7 @@ const canonical = new URL('/', Astro.site);
/* A card waits below the active one and leaves above it, so which side of the /* A card waits below the active one and leaves above it, so which side of the
composition a given card sits on depends on where the story currently is — the composition a given card sits on depends on where the story currently is — the
markup class is only the resting state of the stack before any of this runs. */ markup class is only the resting state of the stack before any of this runs. */
const activateTechnology = (index: number) => { const showTechnology = (index: number) => {
cards.forEach((card, cardIndex) => { cards.forEach((card, cardIndex) => {
const isActive = cardIndex === index; const isActive = cardIndex === index;
card.classList.toggle('is-active', isActive); card.classList.toggle('is-active', isActive);
@@ -1286,7 +1398,46 @@ const canonical = new URL('/', Astro.site);
setFooterVisible(index === cards.length - 1); setFooterVisible(index === cards.length - 1);
}; };
activateTechnology(0); /* Each card holds the stage for a few hundred pixels of scroll, so one coarse
gesture — a flung trackpad, Page Down — crosses a whole card in a single frame.
Its box and the next one's then arrive in the same observer callback and the
middle product is handed the stage and taken off it in the same tick, which
reads as skipping straight past it. Scroll still chooses the card; it just
can't take one away before it has been seen. A card is given the stage one at
a time and holds it for at least its own entrance, so a jump plays the beats it
crossed instead of discarding them. Ordinary scrolling never reaches this —
beats are further apart than the interval. */
const BEAT_MS = 400;
let shownTechnology = 0;
let pendingTechnology = 0;
let shownSince = 0;
let beatTimer = 0;
const playNextBeat = () => {
beatTimer = 0;
if (shownTechnology === pendingTechnology) return;
shownTechnology += pendingTechnology > shownTechnology ? 1 : -1;
showTechnology(shownTechnology);
shownSince = performance.now();
if (shownTechnology !== pendingTechnology) {
beatTimer = window.setTimeout(playNextBeat, BEAT_MS);
}
};
const activateTechnology = (index: number) => {
pendingTechnology = index;
if (beatTimer || shownTechnology === pendingTechnology) return;
const held = performance.now() - shownSince;
if (held >= BEAT_MS) {
playNextBeat();
return;
}
beatTimer = window.setTimeout(playNextBeat, BEAT_MS - held);
};
showTechnology(0);
/* The fixed shell deliberately keeps the masthead and footer outside main's /* The fixed shell deliberately keeps the masthead and footer outside main's
scrollport. Forward gestures that begin there (or in the shell gutter) into scrollport. Forward gestures that begin there (or in the shell gutter) into
@@ -1341,19 +1492,183 @@ const canonical = new URL('/', Astro.site);
document.addEventListener('touchcancel', () => { touchY = null; }, { capture: true, passive: true }); document.addEventListener('touchcancel', () => { touchY = null; }, { capture: true, passive: true });
} }
/* Where the band below reaches: the story hands over to a card when that card's
box crosses this line. Kept in step with the rootMargin. */
const bandBottom = () => (scrollRoot ? scrollRoot.clientHeight : window.innerHeight) * 0.62;
/* Read against the measured offsets below rather than the live boxes. Same test —
an offset is only that box's top with the line already subtracted — but it is
the observer's own crossing expressed as a scroll position, so the two cannot
land on either side of a fraction of a pixel and disagree about which card the
reader is on. It also costs no layout, which matters now that this is asked on
every scrolled frame and not only when a box crosses. */
const technologyAtScroll = () => {
const scrolled = scrollRoot?.scrollTop ?? 0;
let index = 0;
steps.forEach((_, stepIndex) => {
if (scrolled >= (beatEdges[stepIndex] ?? Infinity)) index = Math.max(index, stepIndex);
});
return index;
};
const stepObserver = new IntersectionObserver( const stepObserver = new IntersectionObserver(
(entries) => { /* Read the whole run of boxes rather than only the ones that changed. An entry
entries.forEach((entry) => { reports that a box crossed the band, not which side of the story the reader
if (!entry.isIntersecting) return; is now on — scrolling back to the top, the opening box can pass the band from
const index = Number((entry.target as HTMLElement).dataset.technologyStep); above without ever entering it, and a stack driven by entries alone would sit
if (Number.isInteger(index)) activateTechnology(index); on a card the reader has already scrolled away from. The callback still does
}); the job it is here for: saying that something moved. */
}, () => activateTechnology(technologyAtScroll()),
/* A wider center band advances the next card earlier, reducing the scroll /* A wider center band advances the next card earlier, reducing the scroll
distance between beats without changing the sticky composition. */ distance between beats without changing the sticky composition. */
{ root: scrollRoot, rootMargin: '-38% 0px -38% 0px' }, { root: scrollRoot, rootMargin: '-38% 0px -38% 0px' },
); );
steps.forEach((step) => stepObserver.observe(step)); steps.forEach((step) => stepObserver.observe(step));
/* The same line again, read as scroll offsets rather than as a test: where each
card takes the stage, and where the scroll runs out. The keyboard steps between
these and the progress track measures against them, so both agree with the
observer by construction instead of by a second set of numbers. */
let beatEdges: number[] = [];
/* One segment per card, built from the cards so the two cannot disagree. */
const beatBars = progressTrack
? cards.map(() => progressTrack.appendChild(document.createElement('span')))
: [];
const beatFills = cards.map(() => -1);
const syncProgress = () => {
if (!scrollRoot || !beatBars.length) return;
const scrolled = scrollRoot.scrollTop;
beatBars.forEach((bar, index) => {
const start = beatEdges[index] ?? 0;
const span = Math.max(1, (beatEdges[index + 1] ?? 0) - start);
/* Three decimals is finer than a hairline can show and coarse enough that a
resting scroller stops writing to the DOM at all. */
const fill = Math.round(Math.min(1, Math.max(0, (scrolled - start) / span)) * 1000) / 1000;
if (beatFills[index] === fill) return;
beatFills[index] = fill;
bar.style.setProperty('--beat-fill', String(fill));
});
};
function measureBeats() {
if (!scrollRoot) return;
const end = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight);
const rootTop = scrollRoot.getBoundingClientRect().top;
const line = bandBottom();
beatEdges = [
...steps.map((step) => {
const offset = step.getBoundingClientRect().top - rootTop + scrollRoot.scrollTop - line;
return Math.min(end, Math.max(0, offset));
}),
end,
];
syncProgress();
}
/* Halfway through the beat, not at its edge. A card is handed the stage at the
exact offset its box crosses the line, so landing there leaves the story one
pixel from giving it back. The opening and closing beats rest at the ends of
the scroll instead, where the page itself is the boundary. */
const beatRest = (index: number) => {
const end = beatEdges[beatEdges.length - 1] ?? 0;
if (index <= 0) return 0;
if (index >= steps.length - 1) return end;
return ((beatEdges[index] ?? 0) + (beatEdges[index + 1] ?? end)) / 2;
};
/* The hint has one job and does it once: any scroll at all is proof it has been
read, and it does not come back. An instruction that outlives its own need is
the obtrusive kind. */
let hintRead = false;
const readHint = () => {
if (hintRead) return;
hintRead = true;
progressHint?.classList.add('is-read');
};
if (scrollRoot) {
let progressFrame = 0;
scrollRoot.addEventListener(
'scroll',
() => {
readHint();
if (progressFrame) return;
progressFrame = requestAnimationFrame(() => {
progressFrame = 0;
/* The observer speaks only when a box crosses the band, which is enough
while a gesture keeps going and not enough when one ends: a scroll that
stops just the other side of a crossing gets no further callback, and
the story would sit on the card it was leaving. Every scrolled frame
asks instead. */
activateTechnology(technologyAtScroll());
syncProgress();
});
},
{ passive: true },
);
/* Arrow keys move the story a beat at a time — the cards are the page's only
landmarks, and a keyboard nudge between two of them lands on nothing but a
composition that has not moved. main is the only scrollport and never takes
focus, so the document is where these have to be caught, for the same reason
the wheel and touch gestures are forwarded into it above. */
const KEY_BEAT_MS = 240;
const KEY_RUN_MS = 700;
let keyBeat = 0;
/* Not 0: performance.now() is measured from the page's own start, so a zero here
would read as "a beat just played" and eat the first key of a reader who
reaches for one before the clock has passed the pause below. */
let keyBeatAt = -Infinity;
document.addEventListener('keydown', (event) => {
if (event.defaultPrevented) return;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
const direction = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
if (!direction) return;
const target = event.target as HTMLElement | null;
if (target?.isContentEditable) return;
if (target && /^(?:INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
/* Nothing else on this page scrolls, so the key belongs to the story whether
or not it arrives during the pause below. */
event.preventDefault();
const now = performance.now();
if (now - keyBeatAt < KEY_BEAT_MS) return;
measureBeats();
/* A key pressed while the last jump is still travelling counts from where
that jump was going, not from where the scroll has reached — read the
position instead and a held key stalls, re-deciding from a beat it has not
arrived at yet. Long enough after one, the scroll is the truth again. */
const from = now - keyBeatAt < KEY_RUN_MS ? keyBeat : technologyAtScroll();
keyBeat = Math.min(cards.length - 1, Math.max(0, from + direction));
keyBeatAt = now;
activateTechnology(keyBeat);
scrollRoot.scrollTo({
top: beatRest(keyBeat),
/* Read now rather than at load: the preference can be turned on with the
page already open, and this is the one piece of motion here that a
stylesheet cannot reach. */
behavior: reducedMotion.matches ? 'auto' : 'smooth',
});
readHint();
});
}
measureBeats();
} }
</script> </script>