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

Mirrored from corpo 8248d89.
This commit is contained in:
2026-08-11 20:26:09 -07:00
parent 556aa9d20e
commit e59987f03b
+92 -49
View File
@@ -1458,10 +1458,21 @@ const canonical = new URL('/', Astro.site);
} }
}; };
/* Whatever the scroll says, at once. A card crossed at speed is skipped rather
than queued and played out: a queue makes the stage the one thing on the page
that disagrees with the scroll — the marks are already at the far end and the
cards are still working through the middle, which reads as the stack lagging
rather than as the products it passed being shown. Nothing here waits, so the
marks and the stage are the same reading of the same number. */
let shownTechnology = -1;
/* 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 showTechnology = (index: number) => { const showTechnology = (index: number) => {
if (index === shownTechnology) return;
shownTechnology = index;
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);
@@ -1475,45 +1486,6 @@ const canonical = new URL('/', Astro.site);
setFooterVisible(index === cards.length - 1); setFooterVisible(index === cards.length - 1);
}; };
/* 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); 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
@@ -1588,14 +1560,50 @@ const canonical = new URL('/', Astro.site);
{ capture: true, passive: false }, { capture: true, passive: false },
); );
/* One swipe, one product — the finger's version of the rule above, and it has
to be taken over rather than damped: the momentum a touch scroller hands back
after the finger has gone is the whole of the problem, since it carries a
flick clean across the stack and there is nothing left holding the glass to
ask it not to. So the swipe is given the product it starts on and the one
either side of it, tracks the finger exactly between them, and past them
takes the finger's weight and returns a fifth of it, the way a scroller
resists at its end. Lifting off settles on the product on the stage. A tap is
untouched — nothing is prevented until the finger has actually moved — and so
is a second finger, which is the reader zooming and not scrolling at all. */
const TOUCH_STRETCH = 0.2;
/* And never further than this, however long the swipe goes on. A resistance
that only ever divides still arrives at the next product eventually, and the
reader sees it take the stage and get taken off again on the way back — the
one thing the whole rule is here to prevent. A fifteenth of the screen is far
enough to read as give and much nearer than the next card's edge. */
const TOUCH_STRETCH_MAX = 0.06;
let touchY: number | null = null; let touchY: number | null = null;
let touchBeat = 0;
let touchGive = 0;
/* A tap is a finger that never moved, and settling under one would scroll the
page out from under the link being followed. */
let touchMoved = false;
/* Where the swipe has asked to be, before any of the resistance below: reading
it back off the element instead would compound the resistance against itself,
and the finger would meet a wall rather than a spring. */
let touchTop = 0;
let touchLow = 0;
let touchHigh = 0;
document.addEventListener( document.addEventListener(
'touchstart', 'touchstart',
(event) => { (event) => {
touchY = scrollRoot.contains(event.target as Node) touchY = event.touches.length === 1 ? event.touches[0]?.clientY ?? null : null;
? null if (touchY === null) return;
: event.touches[0]?.clientY ?? null;
measureBeats();
touchMoved = false;
touchBeat = technologyAtScroll();
touchTop = scrollRoot.scrollTop;
touchLow = beatRest(touchBeat - 1);
touchHigh = beatRest(touchBeat + 1);
touchGive = scrollRoot.clientHeight * TOUCH_STRETCH_MAX;
}, },
{ capture: true, passive: true }, { capture: true, passive: true },
); );
@@ -1607,14 +1615,41 @@ const canonical = new URL('/', Astro.site);
if (touchY === null || nextTouchY === undefined) return; if (touchY === null || nextTouchY === undefined) return;
event.preventDefault(); event.preventDefault();
scrollRoot.scrollBy({ top: touchY - nextTouchY, behavior: 'auto' }); touchMoved = true;
touchTop += touchY - nextTouchY;
touchY = nextTouchY; touchY = nextTouchY;
const bound = Math.min(touchHigh, Math.max(touchLow, touchTop));
const past = touchTop - bound;
/* Starts out giving back a fifth and closes on the cap: the same curve a
scroller bounces with, so the swipe meets a spring rather than a wall. */
const give =
Math.sign(past) *
touchGive *
(1 - 1 / (1 + (TOUCH_STRETCH * Math.abs(past)) / touchGive));
scrollRoot.scrollTop = bound + give;
}, },
{ capture: true, passive: false }, { capture: true, passive: false },
); );
document.addEventListener('touchend', () => { touchY = null; }, { capture: true, passive: true }); /* The stage is asked where it is rather than told, so a swipe that gave up
document.addEventListener('touchcancel', () => { touchY = null; }, { capture: true, passive: true }); halfway settles back where it came from — but never further than the one
product the swipe was granted, whatever the stretch above let through. */
const settleTouch = () => {
if (touchY === null) return;
touchY = null;
if (!touchMoved) return;
const beat = Math.min(touchBeat + 1, Math.max(touchBeat - 1, technologyAtScroll()));
scrollRoot.scrollTo({
top: beatRest(beat),
behavior: reducedMotion.matches ? 'auto' : 'smooth',
});
};
document.addEventListener('touchend', settleTouch, { capture: true, passive: true });
document.addEventListener('touchcancel', settleTouch, { capture: true, passive: true });
} }
/* Where the band below reaches: the story hands over to a card when that card's /* Where the band below reaches: the story hands over to a card when that card's
@@ -1643,7 +1678,7 @@ const canonical = new URL('/', Astro.site);
above without ever entering it, and a stack driven by entries alone would sit above without ever entering it, and a stack driven by entries alone would sit
on a card the reader has already scrolled away from. The callback still does on a card the reader has already scrolled away from. The callback still does
the job it is here for: saying that something moved. */ the job it is here for: saying that something moved. */
() => activateTechnology(technologyAtScroll()), () => showTechnology(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' },
@@ -1729,7 +1764,7 @@ const canonical = new URL('/', Astro.site);
stops just the other side of a crossing gets no further callback, and 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 the story would sit on the card it was leaving. Every scrolled frame
asks instead. */ asks instead. */
activateTechnology(technologyAtScroll()); showTechnology(technologyAtScroll());
syncProgress(); syncProgress();
}); });
}, },
@@ -1751,14 +1786,22 @@ const canonical = new URL('/', Astro.site);
const goToBeat = (index: number) => { const goToBeat = (index: number) => {
keyBeat = Math.min(cards.length - 1, Math.max(0, index)); keyBeat = Math.min(cards.length - 1, Math.max(0, index));
keyBeatAt = performance.now(); keyBeatAt = performance.now();
/* A mark asked for by name is a request for that product, not for the ones
in between: scrolled to over a third of a second they would each take the
stage on the way past, which is the same lag the marks are there to spare
the reader. A step to the next one is still travelled. */
const skips = Math.abs(keyBeat - technologyAtScroll()) > 1;
activateTechnology(keyBeat); /* The stage is not told where it is going. It is read off the scroll like
everything else here, so a step that is still travelling shows the product
it is still on rather than announcing the next one and being contradicted
by its own animation a frame later. */
scrollRoot.scrollTo({ scrollRoot.scrollTo({
top: beatRest(keyBeat), top: beatRest(keyBeat),
/* Read now rather than at load: the preference can be turned on with the /* 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 page already open, and this is the one piece of motion here that a
stylesheet cannot reach. */ stylesheet cannot reach. */
behavior: reducedMotion.matches ? 'auto' : 'smooth', behavior: reducedMotion.matches || skips ? 'auto' : 'smooth',
}); });
readHint(); readHint();
}; };