Switching between /mints, /fedimints and /lnurl-mints showed the right list for a moment and then flashed back to the previous ecosystem's mints. The client router keeps every page's script alive, and onReady re-runs each setup on every arrival, so a visited page's setup also ran on the next page. All three grids were marked with the same bare data-mint-grid, so the stale setup found the new grid, fetched its own type and overwrote it. Mark and query each grid by ecosystem (data-mint-grid="cashu" etc.), the way the home page already scopes data-home-grid, so a stale setup finds nothing and bails. Skip the render in hydrateMintGrid when the grid has already been detached by the router. Document the re-run-everywhere contract on onReady, and add a static wiring test so a bare marker cannot come back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* The listener is never removed, on purpose: a layout island (the language switcher,
|
|
* the login dialog) has DOM on every page, and this is what keeps it alive. The flip
|
|
* side is that `setup` runs on *every* page the router swaps in, not only pages that
|
|
* include the script. A page-level script must therefore look its DOM up by a marker
|
|
* unique to that page — `data-mint-grid="cashu"`, `data-home-grid="fedimint"` — and do
|
|
* nothing when the marker is absent. A marker shared between pages means a stale page's
|
|
* setup finds the current page's DOM and rewrites it: the listing pages once flashed
|
|
* back to the previous ecosystem's mints for exactly that reason.
|
|
*/
|
|
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++;
|
|
}
|
|
}
|