Merge nucleic/calm-willow-ibis-dozm into main
Mirrored from corpo dad941a.
This commit is contained in:
+341
-26
@@ -141,6 +141,19 @@ const canonical = new URL('/', Astro.site);
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- The scrollbar is hidden by design, so nothing on the page says
|
||||||
|
there is more of it. This does: a segment per product, the
|
||||||
|
current one filling as its beat is scrolled through. The
|
||||||
|
segments are built by the enhancement from the cards
|
||||||
|
themselves, so a product added to the stack cannot leave a
|
||||||
|
stale one behind. -->
|
||||||
|
<div class="technology-progress">
|
||||||
|
<div class="technology-track" data-technology-track aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<p class="technology-hint" data-technology-hint>
|
||||||
|
Scroll <span class="technology-hint-arrow" aria-hidden="true">↓</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="technology-index" role="status" aria-live="polite" aria-atomic="true">
|
<div class="technology-index" role="status" aria-live="polite" aria-atomic="true">
|
||||||
<span class="technology-index-current" data-technology-progress-current>01</span>
|
<span class="technology-index-current" data-technology-progress-current>01</span>
|
||||||
<span aria-hidden="true">/</span>
|
<span aria-hidden="true">/</span>
|
||||||
@@ -148,6 +161,7 @@ const canonical = new URL('/', Astro.site);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="technology-steps" aria-hidden="true">
|
<div class="technology-steps" aria-hidden="true">
|
||||||
<span data-technology-step="0"></span>
|
<span data-technology-step="0"></span>
|
||||||
@@ -457,6 +471,7 @@ const canonical = new URL('/', Astro.site);
|
|||||||
|
|
||||||
.card-index,
|
.card-index,
|
||||||
.technology-index,
|
.technology-index,
|
||||||
|
.technology-hint,
|
||||||
.footer {
|
.footer {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 0.78rem;
|
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
|
/* 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
|
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 {
|
.hero-lead.has-scroll-stack {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: clamp(66rem, 178svh, 103rem);
|
min-height: clamp(66rem, 178svh, 140rem);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,12 +828,90 @@ const canonical = new URL('/', Astro.site);
|
|||||||
line-height: 1.45;
|
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 {
|
.technology-index {
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
margin-top: 0.75rem;
|
|
||||||
color: var(--muted);
|
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
|
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 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.
|
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 closing box is the other way round: it is the last thing before the page
|
||||||
the same distance from 700px to 1100px tall. */
|
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 {
|
.is-scroll-stack .technology-steps {
|
||||||
display: grid;
|
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
|
/* 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
|
/* 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
|
of it travelling, so a full viewport of it would read as a dead stretch. Three
|
||||||
the phone's pace from the two-card stack across three beats rather than making
|
beats need more of it than two did: at 90svh the last product held the stage
|
||||||
the story a third longer again. */
|
for under 60px, which is inside a single flick. */
|
||||||
.has-scroll-stack .technology-stack {
|
.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
|
/* 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;
|
min-height: 11.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The same unequal split as the desktop stack, retuned: a phone's step boxes are
|
/* The same reasoning as the desktop stack, landing somewhere else: a phone's
|
||||||
read against the same centre band, but the stack starts much further down its
|
stack begins so far down its own scroll that the opening box has nearly
|
||||||
own scroll, so the opening share is larger again. Measured across 640–932px
|
reached the band before the story starts, so here it wants the smallest share
|
||||||
tall, these keep the three products within ~25% of one another. */
|
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 {
|
.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
|
/* 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<HTMLAnchorElement>('[data-technology-card]')];
|
const cards = [...technologyStack.querySelectorAll<HTMLAnchorElement>('[data-technology-card]')];
|
||||||
const steps = [...technologyStack.querySelectorAll<HTMLElement>('[data-technology-step]')];
|
const steps = [...technologyStack.querySelectorAll<HTMLElement>('[data-technology-step]')];
|
||||||
const progressCurrent = technologyStack.querySelector<HTMLElement>('[data-technology-progress-current]');
|
const progressCurrent = technologyStack.querySelector<HTMLElement>('[data-technology-progress-current]');
|
||||||
|
const progressTrack = technologyStack.querySelector<HTMLElement>('[data-technology-track]');
|
||||||
|
const progressHint = technologyStack.querySelector<HTMLElement>('[data-technology-hint]');
|
||||||
const mobileLayout = window.matchMedia('(max-width: 720px)');
|
const mobileLayout = window.matchMedia('(max-width: 720px)');
|
||||||
|
|
||||||
heroLead?.classList.add('has-scroll-stack');
|
heroLead?.classList.add('has-scroll-stack');
|
||||||
@@ -1241,8 +1343,15 @@ const canonical = new URL('/', Astro.site);
|
|||||||
|
|
||||||
syncEnhancedLayout();
|
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) {
|
if ('ResizeObserver' in window && scrollRoot && heroCopy && technologyStage) {
|
||||||
const pinObserver = new ResizeObserver(syncInsets);
|
const pinObserver = new ResizeObserver(() => {
|
||||||
|
syncInsets();
|
||||||
|
measureBeats();
|
||||||
|
});
|
||||||
pinObserver.observe(scrollRoot);
|
pinObserver.observe(scrollRoot);
|
||||||
pinObserver.observe(heroCopy);
|
pinObserver.observe(heroCopy);
|
||||||
pinObserver.observe(technologyStage);
|
pinObserver.observe(technologyStage);
|
||||||
@@ -1253,7 +1362,10 @@ const canonical = new URL('/', Astro.site);
|
|||||||
'resize',
|
'resize',
|
||||||
() => {
|
() => {
|
||||||
cancelAnimationFrame(resizeFrame);
|
cancelAnimationFrame(resizeFrame);
|
||||||
resizeFrame = requestAnimationFrame(syncEnhancedLayout);
|
resizeFrame = requestAnimationFrame(() => {
|
||||||
|
syncEnhancedLayout();
|
||||||
|
measureBeats();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ passive: true },
|
{ 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
|
/* 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 activateTechnology = (index: number) => {
|
const showTechnology = (index: number) => {
|
||||||
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);
|
||||||
@@ -1286,7 +1398,46 @@ const canonical = new URL('/', Astro.site);
|
|||||||
setFooterVisible(index === cards.length - 1);
|
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
|
/* The fixed shell deliberately keeps the masthead and footer outside main's
|
||||||
scrollport. Forward gestures that begin there (or in the shell gutter) into
|
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 });
|
document.addEventListener('touchcancel', () => { touchY = null; }, { capture: true, passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const stepObserver = new IntersectionObserver(
|
/* Where the band below reaches: the story hands over to a card when that card's
|
||||||
(entries) => {
|
box crosses this line. Kept in step with the rootMargin. */
|
||||||
entries.forEach((entry) => {
|
const bandBottom = () => (scrollRoot ? scrollRoot.clientHeight : window.innerHeight) * 0.62;
|
||||||
if (!entry.isIntersecting) return;
|
|
||||||
const index = Number((entry.target as HTMLElement).dataset.technologyStep);
|
/* Read against the measured offsets below rather than the live boxes. Same test —
|
||||||
if (Number.isInteger(index)) activateTechnology(index);
|
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(
|
||||||
|
/* 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
|
/* 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' },
|
||||||
);
|
);
|
||||||
|
|
||||||
steps.forEach((step) => stepObserver.observe(step));
|
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();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user