diff --git a/src/pages/index.astro b/src/pages/index.astro index d577cef..ce67e33 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -141,10 +141,24 @@ const canonical = new URL('/', Astro.site); -
- 01 - - 03 + +
+ + +

+ Scroll +

+ +
+ 01 + + 03 +
@@ -457,6 +471,7 @@ const canonical = new URL('/', Astro.site); .card-index, .technology-index, + .technology-hint, .footer { font-family: 'JetBrains Mono', monospace; 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 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 { position: relative; - min-height: clamp(66rem, 178svh, 103rem); + min-height: clamp(66rem, 178svh, 140rem); align-items: stretch; } @@ -810,12 +828,90 @@ const canonical = new URL('/', Astro.site); 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 { display: none; align-items: center; justify-content: flex-end; gap: 0.45rem; - margin-top: 0.75rem; 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 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. - Measured against the rendered page: these hold each product for within ~10% of - the same distance from 700px to 1100px tall. */ + The closing box is the other way round: it is the last thing before the page + 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 { 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 @@ -1015,11 +1114,11 @@ const canonical = new URL('/', Astro.site); } /* 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 - the phone's pace from the two-card stack across three beats rather than making - the story a third longer again. */ + of it travelling, so a full viewport of it would read as a dead stretch. Three + beats need more of it than two did: at 90svh the last product held the stage + for under 60px, which is inside a single flick. */ .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 @@ -1033,12 +1132,13 @@ const canonical = new URL('/', Astro.site); min-height: 11.9rem; } - /* The same unequal split as the desktop stack, retuned: a phone's step boxes are - read against the same centre band, but the stack starts much further down its - own scroll, so the opening share is larger again. Measured across 640–932px - tall, these keep the three products within ~25% of one another. */ + /* The same reasoning as the desktop stack, landing somewhere else: a phone's + stack begins so far down its own scroll that the opening box has nearly + reached the band before the story starts, so here it wants the smallest share + 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 { - 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 @@ -1144,6 +1244,8 @@ const canonical = new URL('/', Astro.site); const cards = [...technologyStack.querySelectorAll('[data-technology-card]')]; const steps = [...technologyStack.querySelectorAll('[data-technology-step]')]; const progressCurrent = technologyStack.querySelector('[data-technology-progress-current]'); + const progressTrack = technologyStack.querySelector('[data-technology-track]'); + const progressHint = technologyStack.querySelector('[data-technology-hint]'); const mobileLayout = window.matchMedia('(max-width: 720px)'); heroLead?.classList.add('has-scroll-stack'); @@ -1241,8 +1343,15 @@ const canonical = new URL('/', Astro.site); 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) { - const pinObserver = new ResizeObserver(syncInsets); + const pinObserver = new ResizeObserver(() => { + syncInsets(); + measureBeats(); + }); pinObserver.observe(scrollRoot); pinObserver.observe(heroCopy); pinObserver.observe(technologyStage); @@ -1253,7 +1362,10 @@ const canonical = new URL('/', Astro.site); 'resize', () => { cancelAnimationFrame(resizeFrame); - resizeFrame = requestAnimationFrame(syncEnhancedLayout); + resizeFrame = requestAnimationFrame(() => { + syncEnhancedLayout(); + measureBeats(); + }); }, { 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 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 activateTechnology = (index: number) => { + const showTechnology = (index: number) => { cards.forEach((card, cardIndex) => { const isActive = cardIndex === index; card.classList.toggle('is-active', isActive); @@ -1286,7 +1398,46 @@ const canonical = new URL('/', Astro.site); 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 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 }); } + /* 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( - (entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - const index = Number((entry.target as HTMLElement).dataset.technologyStep); - if (Number.isInteger(index)) activateTechnology(index); - }); - }, + /* Read the whole run of boxes rather than only the ones that changed. An entry + reports that a box crossed the band, not which side of the story the reader + is now on — scrolling back to the top, the opening box can pass the band from + 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()), /* 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' }, ); 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(); }