From 53d33d07e64719a629846d6945f2ddb2ec1b300e Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Tue, 11 Aug 2026 19:00:27 -0700 Subject: [PATCH] Merge nucleic/calm-willow-ibis-dozm into main Mirrored from corpo 434d92b. --- src/pages/index.astro | 274 +++++++++++++++++++++++++++++++++--------- 1 file changed, 220 insertions(+), 54 deletions(-) diff --git a/src/pages/index.astro b/src/pages/index.astro index ce67e33..d0025ae 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -142,14 +142,15 @@ const canonical = new URL('/', Astro.site); -
- + +

Scroll

@@ -747,6 +748,29 @@ const canonical = new URL('/', Astro.site); animation: fade-up 700ms 980ms ease-out forwards; } + /* The track stands beside the cards rather than under them: a column of three on + the stack's own right edge reads as a position in a set, where the same marks + laid end to end under the composition read as a loading bar. The cards give up + the width for it; the line of text below them does not, so the readout stays at + the right edge the whole page is ruled to. */ + .is-scroll-stack .technology-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + } + + .is-scroll-stack .technology-cards { + grid-column: 1; + } + + .is-scroll-stack .technology-progress { + grid-column: 1 / -1; + } + + .is-scroll-stack .technology-track { + grid-column: 2; + grid-row: 1; + } + .technology-cards { display: grid; gap: clamp(1.5rem, 3vh, 2.25rem); @@ -832,44 +856,79 @@ const canonical = new URL('/', Astro.site); enhancement's own conditions has none, and says nothing. */ .technology-progress { display: none; - grid-template-columns: 1fr auto; align-items: center; + justify-content: space-between; margin-top: 1.05rem; - row-gap: 0.6rem; - column-gap: 1rem; + gap: 1rem; } .is-scroll-stack .technology-progress { - display: grid; + display: flex; } .technology-track { - display: grid; - grid-column: 1 / -1; - grid-auto-flow: column; + display: none; /* 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; + grid-auto-rows: 1fr; + gap: 0.45rem; + /* The card's own top rule continues across this column, and the marks hang from + it — so the composition keeps the unbroken right edge every other rule on the + page is ruled to, and the track reads as part of it rather than as a widget + parked alongside. */ + border-top: 1px solid var(--line); + padding-top: 0.6rem; } - .technology-track span { + .is-scroll-stack .technology-track { + display: grid; + } + + /* A 2px rule is not a target. The mark itself sits hard against the right edge, + where it terminates the page's own ruling; the button reaches inward from it, so + the hit area grows into empty margin instead of into the card's measure. */ + .technology-track button { 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); + width: 1.9rem; + padding: 0; + border: 0; + background: none; + cursor: pointer; + appearance: none; } - /* 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 { + .technology-track button::before, + .technology-track button::after { position: absolute; - inset: 0; - background: var(--pine); + top: 0; + right: 0; + bottom: 0; + width: 2px; content: ''; - transform: scaleX(var(--beat-fill, 0)); - transform-origin: left; + } + + /* 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. */ + .technology-track button::before { + background: var(--line); + transition: background 200ms ease; + } + + .technology-track button:hover::before { + background: var(--muted); + } + + /* The fill is a scaled bar, not a height: it is written on every scroll frame, and + transform is the one property that costs neither layout nor paint to change. */ + .technology-track button::after { + background: var(--pine); + transform: scaleY(var(--beat-fill, 0)); + transform-origin: top; + } + + .technology-track button:focus-visible { + outline: 2px solid var(--sage); + outline-offset: 3px; } .technology-hint { @@ -910,7 +969,6 @@ const canonical = new URL('/', Astro.site); .technology-index { display: none; align-items: center; - justify-content: flex-end; gap: 0.45rem; color: var(--muted); } @@ -1248,6 +1306,23 @@ const canonical = new URL('/', Astro.site); const progressHint = technologyStack.querySelector('[data-technology-hint]'); const mobileLayout = window.matchMedia('(max-width: 720px)'); + /* One control per card, built from the cards so the two cannot disagree about how + many products there are. A button rather than a bare rule: this is a way into + the story as well as a report on it, and a button is the one element that + already knows how to be clicked, focused and pressed with a key. */ + const beatBars = progressTrack + ? cards.map((card, index) => { + const bar = document.createElement('button'); + bar.type = 'button'; + /* The card's own name, minus the arrow glyph that marks it as a link — the + bar goes to the product on this page, it does not leave for its site. */ + const name = card.querySelector('.product-name')?.textContent?.replace(/↗/g, '').trim(); + bar.setAttribute('aria-label', name || `Product ${index + 1}`); + return progressTrack.appendChild(bar); + }) + : []; + const beatFills = cards.map(() => -1); + heroLead?.classList.add('has-scroll-stack'); hero?.classList.add('has-scroll-story'); technologyStack.classList.add('is-scroll-stack'); @@ -1392,6 +1467,7 @@ const canonical = new URL('/', Astro.site); card.classList.toggle('product-card-next', cardIndex > index); card.toggleAttribute('inert', !isActive); card.setAttribute('aria-hidden', String(!isActive)); + beatBars[cardIndex]?.setAttribute('aria-current', String(isActive)); }); if (progressCurrent) progressCurrent.textContent = String(index + 1).padStart(2, '0'); @@ -1440,25 +1516,100 @@ const canonical = new URL('/', Astro.site); showTechnology(0); /* The fixed shell deliberately keeps the masthead and footer outside main's - scrollport. Forward gestures that begin there (or in the shell gutter) into - main, so the story responds everywhere instead of only between the rules. */ + scrollport, and the wheel is driven from here for everything — gestures that + begin in those margins, so the story responds everywhere instead of only + between the rules, and gestures over main itself, so the tail of a flick can + be given some weight on the way through a beat. */ if (scrollRoot) { - const scrollMainBy = (event: WheelEvent) => { + const wheelPixels = (event: WheelEvent) => { const multiplier = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? scrollRoot.clientHeight : 1; - scrollRoot.scrollBy({ top: event.deltaY * multiplier, behavior: 'auto' }); + return event.deltaY * multiplier; + }; + + /* Trackpad momentum arrives as a long tail of wheel deltas, and one hard flick + carries enough of it to cross the whole stack — the products in the middle are + then only a flicker on the way past, which is the one thing the beat pacing + above cannot fix, because by the time it has played a card the scroll is + already somewhere else. So a step entered at fling speed is entered slowly. + The rule the whole thing reduces to: a gesture may cross any boundary it + reaches at reading pace, and the moment it crosses one at fling speed, what is + left of that fling is held to a sixth of its weight. One flick, one product; + a reader who keeps pushing still gets through, just against the brakes. */ + const CATCH_STRENGTH = 0.85; + /* Below the first, a gesture is reading and is never touched; above the second + it is a fling. Both are per-frame, so they are speeds a hand can feel — about + 720px and 1800px a second — rather than counts of an event nobody controls. */ + const SLOW_FRAME_PX = 12; + const FAST_FRAME_PX = 30; + const GESTURE_GAP_MS = 140; + + let wheelAt = -Infinity; + let wheelSpeed = 0; + let gestureBeat = -1; + /* How hard this gesture was going at the boundary it was caught at, held for the + rest of it: momentum decays, and a brake that eased off with it would let go + exactly when the tail is long enough to reach the next product. Nought while + the gesture is still free. */ + let gestureCatch = 0; + + const beatDrag = () => { + const beat = technologyAtScroll(); + if (beat === gestureBeat) return 1; + + if (!gestureCatch) { + const fast = Math.min( + 1, + Math.max(0, (wheelSpeed - SLOW_FRAME_PX) / (FAST_FRAME_PX - SLOW_FRAME_PX)), + ); + /* Reading pace: the gesture is entitled to the beat it has reached, and to + the next one it reaches the same way. Only speed is ever held back. */ + if (fast <= 0) { + gestureBeat = beat; + return 1; + } + gestureCatch = fast; + } + + /* Flat rather than a ramp that fades across the step. A ramp charges a fling + only the log of its weight — measured, it took barely a tenth off a hard + one — where a flat brake charges it in proportion, which is what makes a + flick spend itself inside one product instead of three. */ + return 1 - CATCH_STRENGTH * gestureCatch; }; document.addEventListener( 'wheel', (event) => { - if (scrollRoot.contains(event.target as Node) || event.deltaY === 0) return; + /* A ctrl-wheel is the browser's own zoom, not a scroll to be taken over. */ + if (event.ctrlKey || event.deltaY === 0) return; event.preventDefault(); - scrollMainBy(event); + + const now = performance.now(); + const gap = now - wheelAt; + const pixels = wheelPixels(event); + /* Per frame rather than per event, since a mouse notch and a trackpad + sample arrive at nothing like the same rate. The gap is held between a + quarter frame and four: below, two events sharing a millisecond would + read as impossibly fast, and above, the first event of a gesture would + read as a crawl. */ + const frames = Math.max(4, Math.min(gap, 64)) / 16; + const instant = Math.abs(pixels) / frames; + + if (gap > GESTURE_GAP_MS) { + wheelSpeed = instant; + gestureBeat = technologyAtScroll(); + gestureCatch = 0; + } else { + wheelSpeed = wheelSpeed * 0.65 + instant * 0.35; + } + wheelAt = now; + + scrollRoot.scrollBy({ top: pixels * beatDrag(), behavior: 'auto' }); }, { capture: true, passive: false }, ); @@ -1532,12 +1683,6 @@ const canonical = new URL('/', Astro.site); 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; @@ -1617,11 +1762,10 @@ const canonical = new URL('/', Astro.site); { 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. */ + /* Keys and the track's own buttons both move the story a beat at a time — the + cards are the page's only landmarks, and a jump to anywhere between two of + them lands on nothing but a composition that has not moved. Callers measure + first: this reads the edges, and a jump is worth one fresh reading of them. */ const KEY_BEAT_MS = 240; const KEY_RUN_MS = 700; let keyBeat = 0; @@ -1630,11 +1774,36 @@ const canonical = new URL('/', Astro.site); reaches for one before the clock has passed the pause below. */ let keyBeatAt = -Infinity; + const goToBeat = (index: number) => { + keyBeat = Math.min(cards.length - 1, Math.max(0, index)); + keyBeatAt = performance.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(); + }; + + /* Down and right advance, up and left go back. Both pairs, because the story is + a horizontal set of cards read by scrolling vertically, and which axis a + reader reaches for depends on which of those two they are looking at. 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 + driven from there above. */ 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; + const direction = event.key === 'ArrowDown' || event.key === 'ArrowRight' + ? 1 + : event.key === 'ArrowUp' || event.key === 'ArrowLeft' + ? -1 + : 0; if (!direction) return; const target = event.target as HTMLElement | null; @@ -1654,18 +1823,15 @@ const canonical = new URL('/', Astro.site); 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; + goToBeat(from + direction); + }); - 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', + /* The bars report where the story is; clicking one says where it should be. */ + beatBars.forEach((bar, index) => { + bar.addEventListener('click', () => { + measureBeats(); + goToBeat(index); }); - readHint(); }); }