Merge nucleic/calm-willow-ibis-dozm into main
Mirrored from corpo 434d92b.
This commit is contained in:
+220
-54
@@ -142,14 +142,15 @@ const canonical = new URL('/', Astro.site);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The scrollbar is hidden by design, so nothing on the page says
|
<!-- The scrollbar is hidden by design, so nothing on the page says
|
||||||
there is more of it. This does: a segment per product, the
|
there is more of it. This does: a segment per product standing
|
||||||
current one filling as its beat is scrolled through. The
|
in a column beside them, the current one filling as its beat is
|
||||||
|
scrolled through, each one a button into its own product. The
|
||||||
segments are built by the enhancement from the cards
|
segments are built by the enhancement from the cards
|
||||||
themselves, so a product added to the stack cannot leave a
|
themselves, so a product added to the stack cannot leave a
|
||||||
stale one behind. -->
|
stale one behind. -->
|
||||||
<div class="technology-progress">
|
<nav class="technology-track" data-technology-track aria-label="Products"></nav>
|
||||||
<div class="technology-track" data-technology-track aria-hidden="true"></div>
|
|
||||||
|
|
||||||
|
<div class="technology-progress">
|
||||||
<p class="technology-hint" data-technology-hint>
|
<p class="technology-hint" data-technology-hint>
|
||||||
Scroll <span class="technology-hint-arrow" aria-hidden="true">↓</span>
|
Scroll <span class="technology-hint-arrow" aria-hidden="true">↓</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -747,6 +748,29 @@ const canonical = new URL('/', Astro.site);
|
|||||||
animation: fade-up 700ms 980ms ease-out forwards;
|
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 {
|
.technology-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: clamp(1.5rem, 3vh, 2.25rem);
|
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. */
|
enhancement's own conditions has none, and says nothing. */
|
||||||
.technology-progress {
|
.technology-progress {
|
||||||
display: none;
|
display: none;
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
margin-top: 1.05rem;
|
margin-top: 1.05rem;
|
||||||
row-gap: 0.6rem;
|
gap: 1rem;
|
||||||
column-gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-scroll-stack .technology-progress {
|
.is-scroll-stack .technology-progress {
|
||||||
display: grid;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.technology-track {
|
.technology-track {
|
||||||
display: grid;
|
display: none;
|
||||||
grid-column: 1 / -1;
|
|
||||||
grid-auto-flow: column;
|
|
||||||
/* Equal shares on purpose, unlike the step boxes they measure: these say which
|
/* 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. */
|
product of how many, not how much scroll each one happens to cost. */
|
||||||
grid-auto-columns: 1fr;
|
grid-auto-rows: 1fr;
|
||||||
gap: 0.4rem;
|
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;
|
position: relative;
|
||||||
height: 2px;
|
width: 1.9rem;
|
||||||
/* Same hairline as every rule on the page, so the empty track reads as part of
|
padding: 0;
|
||||||
the composition rather than as a control sitting on top of it. */
|
border: 0;
|
||||||
background: var(--line);
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The fill is a scaled bar, not a width: it is written on every scroll frame, and
|
.technology-track button::before,
|
||||||
transform is the one property that costs neither layout nor paint to change. */
|
.technology-track button::after {
|
||||||
.technology-track span::after {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
top: 0;
|
||||||
background: var(--pine);
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
content: '';
|
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 {
|
.technology-hint {
|
||||||
@@ -910,7 +969,6 @@ const canonical = new URL('/', Astro.site);
|
|||||||
.technology-index {
|
.technology-index {
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -1248,6 +1306,23 @@ const canonical = new URL('/', Astro.site);
|
|||||||
const progressHint = technologyStack.querySelector<HTMLElement>('[data-technology-hint]');
|
const progressHint = technologyStack.querySelector<HTMLElement>('[data-technology-hint]');
|
||||||
const mobileLayout = window.matchMedia('(max-width: 720px)');
|
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');
|
heroLead?.classList.add('has-scroll-stack');
|
||||||
hero?.classList.add('has-scroll-story');
|
hero?.classList.add('has-scroll-story');
|
||||||
technologyStack.classList.add('is-scroll-stack');
|
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.classList.toggle('product-card-next', cardIndex > index);
|
||||||
card.toggleAttribute('inert', !isActive);
|
card.toggleAttribute('inert', !isActive);
|
||||||
card.setAttribute('aria-hidden', String(!isActive));
|
card.setAttribute('aria-hidden', String(!isActive));
|
||||||
|
beatBars[cardIndex]?.setAttribute('aria-current', String(isActive));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (progressCurrent) progressCurrent.textContent = String(index + 1).padStart(2, '0');
|
if (progressCurrent) progressCurrent.textContent = String(index + 1).padStart(2, '0');
|
||||||
@@ -1440,25 +1516,100 @@ const canonical = new URL('/', Astro.site);
|
|||||||
showTechnology(0);
|
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, and the wheel is driven from here for everything — gestures that
|
||||||
main, so the story responds everywhere instead of only between the rules. */
|
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) {
|
if (scrollRoot) {
|
||||||
const scrollMainBy = (event: WheelEvent) => {
|
const wheelPixels = (event: WheelEvent) => {
|
||||||
const multiplier = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
const multiplier = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||||
? 16
|
? 16
|
||||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
||||||
? scrollRoot.clientHeight
|
? scrollRoot.clientHeight
|
||||||
: 1;
|
: 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(
|
document.addEventListener(
|
||||||
'wheel',
|
'wheel',
|
||||||
(event) => {
|
(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();
|
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 },
|
{ 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. */
|
observer by construction instead of by a second set of numbers. */
|
||||||
let beatEdges: number[] = [];
|
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 = () => {
|
const syncProgress = () => {
|
||||||
if (!scrollRoot || !beatBars.length) return;
|
if (!scrollRoot || !beatBars.length) return;
|
||||||
|
|
||||||
@@ -1617,11 +1762,10 @@ const canonical = new URL('/', Astro.site);
|
|||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Arrow keys move the story a beat at a time — the cards are the page's only
|
/* Keys and the track's own buttons both move the story a beat at a time — the
|
||||||
landmarks, and a keyboard nudge between two of them lands on nothing but a
|
cards are the page's only landmarks, and a jump to anywhere between two of
|
||||||
composition that has not moved. main is the only scrollport and never takes
|
them lands on nothing but a composition that has not moved. Callers measure
|
||||||
focus, so the document is where these have to be caught, for the same reason
|
first: this reads the edges, and a jump is worth one fresh reading of them. */
|
||||||
the wheel and touch gestures are forwarded into it above. */
|
|
||||||
const KEY_BEAT_MS = 240;
|
const KEY_BEAT_MS = 240;
|
||||||
const KEY_RUN_MS = 700;
|
const KEY_RUN_MS = 700;
|
||||||
let keyBeat = 0;
|
let keyBeat = 0;
|
||||||
@@ -1630,11 +1774,36 @@ const canonical = new URL('/', Astro.site);
|
|||||||
reaches for one before the clock has passed the pause below. */
|
reaches for one before the clock has passed the pause below. */
|
||||||
let keyBeatAt = -Infinity;
|
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) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
if (event.defaultPrevented) return;
|
if (event.defaultPrevented) return;
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) 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;
|
if (!direction) return;
|
||||||
|
|
||||||
const target = event.target as HTMLElement | null;
|
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
|
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. */
|
arrived at yet. Long enough after one, the scroll is the truth again. */
|
||||||
const from = now - keyBeatAt < KEY_RUN_MS ? keyBeat : technologyAtScroll();
|
const from = now - keyBeatAt < KEY_RUN_MS ? keyBeat : technologyAtScroll();
|
||||||
keyBeat = Math.min(cards.length - 1, Math.max(0, from + direction));
|
goToBeat(from + direction);
|
||||||
keyBeatAt = now;
|
});
|
||||||
|
|
||||||
activateTechnology(keyBeat);
|
/* The bars report where the story is; clicking one says where it should be. */
|
||||||
scrollRoot.scrollTo({
|
beatBars.forEach((bar, index) => {
|
||||||
top: beatRest(keyBeat),
|
bar.addEventListener('click', () => {
|
||||||
/* Read now rather than at load: the preference can be turned on with the
|
measureBeats();
|
||||||
page already open, and this is the one piece of motion here that a
|
goToBeat(index);
|
||||||
stylesheet cannot reach. */
|
|
||||||
behavior: reducedMotion.matches ? 'auto' : 'smooth',
|
|
||||||
});
|
});
|
||||||
readHint();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user