From e59987f03bca9035f05ece48817fc2fd2b4c0216 Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Tue, 11 Aug 2026 20:26:09 -0700 Subject: [PATCH] Merge nucleic/calm-willow-ibis-dozm into main Mirrored from corpo 8248d89. --- src/pages/index.astro | 141 +++++++++++++++++++++++++++--------------- 1 file changed, 92 insertions(+), 49 deletions(-) diff --git a/src/pages/index.astro b/src/pages/index.astro index bc50272..a60c9b9 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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 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. */ const showTechnology = (index: number) => { + if (index === shownTechnology) return; + shownTechnology = index; + cards.forEach((card, cardIndex) => { const isActive = cardIndex === index; card.classList.toggle('is-active', isActive); @@ -1475,45 +1486,6 @@ const canonical = new URL('/', Astro.site); 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); /* 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 }, ); + /* 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 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( 'touchstart', (event) => { - touchY = scrollRoot.contains(event.target as Node) - ? null - : event.touches[0]?.clientY ?? null; + touchY = event.touches.length === 1 ? event.touches[0]?.clientY ?? null : null; + if (touchY === null) return; + + 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 }, ); @@ -1607,14 +1615,41 @@ const canonical = new URL('/', Astro.site); if (touchY === null || nextTouchY === undefined) return; event.preventDefault(); - scrollRoot.scrollBy({ top: touchY - nextTouchY, behavior: 'auto' }); + touchMoved = true; + touchTop += 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 }, ); - document.addEventListener('touchend', () => { touchY = null; }, { capture: true, passive: true }); - document.addEventListener('touchcancel', () => { touchY = null; }, { capture: true, passive: true }); + /* The stage is asked where it is rather than told, so a swipe that gave up + 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 @@ -1643,7 +1678,7 @@ const canonical = new URL('/', Astro.site); 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 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 distance between beats without changing the sticky composition. */ { 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 the story would sit on the card it was leaving. Every scrolled frame asks instead. */ - activateTechnology(technologyAtScroll()); + showTechnology(technologyAtScroll()); syncProgress(); }); }, @@ -1751,14 +1786,22 @@ const canonical = new URL('/', Astro.site); const goToBeat = (index: number) => { keyBeat = Math.min(cards.length - 1, Math.max(0, index)); 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({ 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', + behavior: reducedMotion.matches || skips ? 'auto' : 'smooth', }); readHint(); };