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();
});
}