Merge nucleic/calm-willow-ibis-dozm into main

Mirrored from corpo 434d92b.
This commit is contained in:
2026-08-11 19:00:27 -07:00
parent 1d36fe6ff6
commit 53d33d07e6
+220 -54
View File
@@ -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();
}); });
} }