first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+358
View File
@@ -0,0 +1,358 @@
/**
* The site's one motion utility. No animation library: everything visual is CSS,
* and this file only decides *when* a CSS class goes on.
*
* Four jobs:
* 1. `initReveal` watches `[data-reveal]` elements and adds `.in` at 15% visibility,
* once, with a stagger so a row of cards arrives as a sequence.
* 2. `onReady` runs an island's setup on first paint and again after a view
* transition, because a module script that has already run is never re-executed
* by the router (astro/dist/transitions/swap-functions.js marks it as done).
* 3. `countUp` and `swapText` animate a number into place.
* 4. `enterStagger` gives island-rendered rows the same entrance as prerendered ones.
*
* Every one of them checks `prefers-reduced-motion` first and lands on the final
* state immediately when it is set. The CSS carries the same gate, so a preference
* changed mid-visit is honoured too.
*/
import { intlTag } from '../i18n/config';
import { useI18n } from '../i18n/client';
/** Delay between items in a staggered group. `--stagger` in global.css. */
const STEP_MS = 50;
/** Nothing waits longer than this to arrive, however long the list is. `--stagger-cap`. */
const CAP_MS = 400;
/** Fraction of an element that has to be on screen before it counts as arrived. */
const RATIO = 0.15;
export function prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** The stagger delay for item `index` of a group, capped so the tail is not left waiting. */
export function staggerDelay(index: number, step = STEP_MS, cap = CAP_MS): number {
return Math.min(index * step, cap);
}
/**
* `smooth` unless the reader asked for less motion, in which case scrolling jumps.
* A programmatic smooth scroll is movement like any other.
*/
export function scrollBehavior(): ScrollBehavior {
return prefersReducedMotion() ? 'auto' : 'smooth';
}
/* ---------- reveal on scroll ---------- */
let observer: IntersectionObserver | null = null;
/**
* True once the router has swapped a page in, so an island can tell a full page load
* from an arrival by view transition. The hero uses it: its entrance is a greeting
* for someone who has just loaded the site, not something to replay every time they
* come back to the home page.
*/
let arrived = false;
// Guarded: lib/skeleton.ts imports from here and is used in build-time frontmatter,
// so this module is evaluated on the server as well as in the browser.
if (typeof document !== 'undefined') {
document.addEventListener('astro:after-swap', () => {
arrived = true;
});
}
export function arrivedByRouter(): boolean {
return arrived;
}
function reveal(el: HTMLElement, delay: number, instant = false): void {
if (instant) {
// Straight to the final state with no transition to run through on the way.
el.style.transition = 'none';
el.classList.add('in');
void el.offsetWidth;
el.style.transition = '';
el.dispatchEvent(new CustomEvent('reveal'));
return;
}
if (delay > 0) el.style.setProperty('--reveal-delay', `${delay}ms`);
el.classList.add('in');
// Islands hang count-ups and bar fills off this rather than running their own
// observer. `whenRevealed` below covers the case where the listener is late.
el.dispatchEvent(new CustomEvent('reveal'));
}
function onIntersect(entries: IntersectionObserverEntry[], obs: IntersectionObserver): void {
const viewport = window.innerHeight || 1;
// An element taller than the viewport can never reach 15% of itself on screen, so
// it also counts as arrived once 15% of a screenful of it is showing.
const arrived = entries.filter(
(entry) =>
entry.isIntersecting &&
(entry.intersectionRatio >= RATIO || entry.intersectionRect.height >= viewport * RATIO),
);
// Reading order, so a grid row lights up left to right rather than in whatever
// order the observer happened to queue them.
arrived.sort(
(a, b) =>
a.boundingClientRect.top - b.boundingClientRect.top ||
a.boundingClientRect.left - b.boundingClientRect.left,
);
arrived.forEach((entry, index) => {
const el = entry.target as HTMLElement;
obs.unobserve(el);
const explicit = el.dataset['revealDelay'];
// No explicit delay means "stagger with whatever else arrived in this batch",
// which is what keeps a long grid from dumping a whole screen at once and keeps
// a row scrolled into view later from waiting on the index of the row above it.
reveal(el, explicit === undefined ? staggerDelay(index) : Number(explicit));
});
}
/** Start watching every unrevealed `[data-reveal]` under `root`. Safe to call twice. */
export function initReveal(root: ParentNode = document): void {
const targets = [...root.querySelectorAll<HTMLElement>('[data-reveal]:not(.in)')];
if (targets.length === 0) return;
if (prefersReducedMotion() || typeof IntersectionObserver === 'undefined') {
for (const el of targets) reveal(el, 0, true);
return;
}
observer ??= new IntersectionObserver(onIntersect, { threshold: [0, RATIO] });
for (const el of targets) {
// `data-reveal="load"` is a first-impression entrance: it plays on a full page
// load and never again, so coming back to the page through the router does not
// replay it.
if (el.dataset['reveal'] === 'load' && arrived) {
reveal(el, 0, true);
continue;
}
observer.observe(el);
}
}
/** Drop the observer before the router swaps the body out from under it. */
export function resetReveal(): void {
observer?.disconnect();
observer = null;
}
/**
* Run `callback` when `el` has arrived, now if it already has.
*
* Islands cannot rely on being listening before the observer fires, so the check for
* `.in` is the important half of this.
*/
export function whenRevealed(el: Element, callback: () => void): void {
if (el.classList.contains('in')) {
callback();
return;
}
el.addEventListener('reveal', callback, { once: true });
}
/* ---------- shared element page transitions ---------- */
/**
* Hand the card being clicked the view transition names that its twins in the mint
* page header already carry, so the icon and the name morph across the navigation
* instead of cross-fading with the rest of the page.
*
* Only the clicked card gets them. A named element is captured and composited on its
* own, and /mints has 58 cards: naming them all up front would mean 116 separate
* textures per navigation to animate 2 of them. The names are cleared again after the
* swap, and if the reader cancels the navigation the stale names go with the page.
*
* Capture phase, so this runs before the router's own click handler starts the
* transition and takes its snapshot of the outgoing page.
*/
export function armSharedTransitions(): void {
if (typeof document === 'undefined') return;
// Only ever the cards' own two elements. The mint page header carries its half of
// the pair as a permanent inline style, and clearing that would take the morph's
// other end with it.
const clear = (): void => {
for (const el of document.querySelectorAll<HTMLElement>('[data-vt-icon], [data-vt-name]')) {
el.style.removeProperty('view-transition-name');
}
};
document.addEventListener(
'click',
(event) => {
const card = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-mint-card]');
if (!card) return;
clear();
const icon = card.querySelector<HTMLElement>('[data-vt-icon]');
const name = card.querySelector<HTMLElement>('[data-vt-name]');
if (icon?.dataset['vtIcon']) icon.style.setProperty('view-transition-name', icon.dataset['vtIcon']);
if (name?.dataset['vtName']) name.style.setProperty('view-transition-name', name.dataset['vtName']);
},
true,
);
document.addEventListener('astro:after-swap', clear);
}
/* ---------- island lifecycle ---------- */
/**
* Run an island's setup on this page, and again on every page the router swaps in.
*
* The router never re-executes a module script it has already run, so an island that
* only does its work at module scope is dead after the first navigation. It does fire
* `astro:page-load` on every arrival, including the first, but on the first it waits
* for `window.load`, which is far too late to be the only trigger. Hence both.
*/
export function onReady(setup: () => void): void {
let done = false;
const run = (): void => {
if (done) return;
done = true;
setup();
};
// Module scripts are deferred, so the document is parsed by the time this runs.
run();
document.addEventListener('astro:after-swap', () => {
done = false;
});
document.addEventListener('astro:page-load', run);
}
/** Register a teardown to run just before the router replaces this page. */
export function onLeave(teardown: () => void): void {
document.addEventListener('astro:before-swap', teardown, { once: true });
}
/* ---------- numbers ---------- */
const frames = new WeakMap<Element, number>();
/** Stop an in-flight count, so a live update never fights the count-up. */
export function cancelCount(el: Element): void {
const running = frames.get(el);
if (running !== undefined) {
cancelAnimationFrame(running);
frames.delete(el);
if (el instanceof HTMLElement) el.style.minWidth = '';
}
}
/** Decelerating, so the number lands rather than stopping dead. */
const easeOut = (t: number): number => 1 - Math.pow(1 - t, 3);
export interface CountUpOptions {
/** Decimal places. 0 for counts, 1 for a rating. */
decimals?: number;
duration?: number;
/** Overrides the default locale formatting (used for thousands separators). */
format?: (value: number) => string;
}
/**
* Count `el` from zero up to `to`.
*
* Interrupting an in-flight count cancels it rather than stacking a second one, and
* with reduced motion set the final value is written straight away.
*/
export function countUp(el: HTMLElement, to: number, options: CountUpOptions = {}): void {
const { decimals = 0, duration = 350, format } = options;
/*
* The locale comes from the page, not from the browser's own default: a Dutch
* reader with an en-US machine is reading a Dutch page, and 4,6 counting up to 4.6
* would be the number changing notation halfway through the animation.
*/
const locale = intlTag(useI18n().locale);
const render = (value: number): string =>
format ? format(value) : value.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
cancelCount(el);
if (prefersReducedMotion() || !Number.isFinite(to)) {
el.textContent = render(to);
return;
}
/*
* Hold the width the final value needs before counting up to it.
*
* "1,247" is wider than "4", and without this the numbers either side of it in the
* pulse strip would shuffle for the length of the count. Tabular figures fix the
* digits, not how many of them there are.
*/
el.textContent = render(to);
el.style.minWidth = `${el.getBoundingClientRect().width}px`;
const start = performance.now();
const step = (now: number): void => {
const progress = Math.min(1, (now - start) / duration);
el.textContent = render(to * easeOut(progress));
if (progress < 1) {
frames.set(el, requestAnimationFrame(step));
} else {
frames.delete(el);
el.textContent = render(to);
el.style.minWidth = '';
}
};
frames.set(el, requestAnimationFrame(step));
}
/**
* Replace text with a dip and a 4px rise, for a value that changed under the reader
* rather than one arriving for the first time.
*/
export function swapText(el: HTMLElement, text: string): void {
// A count-up still running on this element would keep writing over the new value.
cancelCount(el);
if (el.textContent === text) return;
if (prefersReducedMotion()) {
el.textContent = text;
return;
}
el.classList.remove('num-swap');
// Reading offsetWidth restarts the animation when a value changes twice quickly.
void el.offsetWidth;
el.classList.add('num-swap');
window.setTimeout(() => {
el.textContent = text;
}, 100);
el.addEventListener('animationend', () => el.classList.remove('num-swap'), { once: true });
}
/* ---------- island rendered rows ---------- */
/**
* Give freshly rendered rows the same entrance prerendered ones get.
*
* The class is removed when the animation ends so a later re-render can re-apply it,
* and rows already on screen are not re-animated by a filter change they survived.
*/
export function enterStagger(elements: Iterable<HTMLElement>, step = STEP_MS): void {
if (prefersReducedMotion()) return;
let index = 0;
for (const el of elements) {
el.style.setProperty('--reveal-delay', `${staggerDelay(index, step)}ms`);
el.classList.add('entering');
el.addEventListener(
'animationend',
() => {
el.classList.remove('entering');
el.style.removeProperty('--reveal-delay');
},
{ once: true },
);
index++;
}
}