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
+658
View File
@@ -0,0 +1,658 @@
---
import Base from '../../layouts/Base.astro';
import ColorBends from '../../components/ColorBends.astro';
import MintCard from '../../components/MintCard.astro';
import PulseTicker from '../../components/PulseTicker.astro';
import { fetchHealth, fetchMint, fetchMints, fetchStats } from '../../lib/api';
import { fetchLatestReviews } from '../../lib/nostr-build';
import { displayDomain, iconGradient, sentiment, shortNpub } from '../../lib/format';
import { profileName, readCapabilities } from '@cashumints/shared';
import { localePath, useI18n, type Locale } from '../../i18n';
import { formatters } from '../../i18n/format';
import { localePaths } from '../../i18n/paths';
/** One page per locale: `/`, `/es`, `/nl`. */
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
const f = formatters(t);
const [mints, stats, health] = await Promise.all([fetchMints(), fetchStats(), fetchHealth()]);
const top = mints.slice(0, 6);
const hostByUrl = new Map(mints.map((m) => [m.url, m.host]));
/*
* The mint details and the relay read have nothing to say to each other — the reviews
* only need `hostByUrl`, which the mint list above already gave us — so they wait
* together rather than one behind the other.
*
* The carousel holds a screenful and a bit: enough that scrolling it is worth doing,
* few enough that they are all real, recent, written reviews. Anything the relays
* cannot fill is simply fewer cards, and the carousel's controls disappear with them.
*/
const [details, latest] = await Promise.all([
// Distributions give the card sentiment bars real data instead of a rating proxy.
Promise.all(top.map((m) => fetchMint(m.host).catch(() => null))),
fetchLatestReviews(hostByUrl, 10),
]);
const sentiments = details.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
// Same details, read a second way: the card's "melt only" or "frozen" chip.
const capabilities = details.map((d) => (d ? readCapabilities(d.info?.nuts) : null));
const description = t('home.description', {
mints: stats.mints_total,
reviews: stats.reviews_total,
});
/*
* The one sentence on the page with a link inside it. Interpolated as markup rather
* than split into three strings, so a translator can put "NIP-87" wherever the
* sentence needs it instead of wherever English left a gap.
*/
const signedBody = t('home.why.signed.body', {
link: `<a href="${localePath('/about', locale)}#nip87">${t('home.why.signed.link')}</a>`,
});
---
<Base title={t('home.title')} description={description} current="mints">
{/*
The hero sits on an animated colour field: ColorBends from React Bits, ported to
plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it
is full width, so the wash runs edge to edge rather than stopping at the 1240px
column the text keeps to.
*/}
<div class="hero-wrap">
<ColorBends
rotation={90}
speed={0.2}
colors={['#A78BFA']}
transparent
autoRotate={1}
scale={0.4}
frequency={1}
warpStrength={1}
mouseInfluence={0.5}
parallax={0.2}
noise={0.2}
iterations={1}
intensity={1.3}
bandWidth={7.5}
/>
{/*
The hero arrives as a sequence, 60ms apart, starting the moment the page is
parsed. `data-reveal="load"` means exactly once, on a full page load: coming back
here through the router puts it on screen without replaying the greeting.
*/}
<section class="hero">
{/*
Two strings, because the emphasised half is a phrase and not the tail of a
sentence: Spanish and Dutch both put "you can trust" somewhere English does not,
and a translator needs to be able to move it.
*/}
<h1 data-reveal="load" data-reveal-delay="0">
{t('home.hero.title')} <em>{t('home.hero.titleEm')}</em>
</h1>
<p data-reveal="load" data-reveal-delay="60">{t('home.hero.lede')}</p>
<div class="search-wrap">
<form
class="search"
action={localePath('/mints', locale)}
method="get"
role="search"
data-reveal="load"
data-reveal-delay="120"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
</svg>
<label class="sr-only" for="home-search">{t('home.search.label')}</label>
<input
id="home-search"
name="q"
type="text"
autocomplete="off"
placeholder={t('home.search.placeholder')}
data-search-input
/>
<kbd aria-hidden="true">/</kbd>
</form>
<div class="quick-filters" data-reveal="load" data-reveal-delay="180">
<a class="qf" href={`${localePath('/mints', locale)}?sort=reviews`}>{t('home.filters.mostReviewed')}</a>
<a class="qf" href={`${localePath('/mints', locale)}?sort=rating`}>{t('home.filters.highestRated')}</a>
<a class="qf" href={`${localePath('/mints', locale)}?sort=recent`}>{t('home.filters.recentlyActive')}</a>
<a class="qf" href={`${localePath('/mints', locale)}?nut=17`}>{t('home.filters.websockets')}</a>
</div>
</div>
</section>
</div>
<PulseTicker stats={stats} health={health} />
<section class="section" aria-labelledby="top-mints">
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
<span class="sec-sub">{t('home.top.sub')}</span>
<a class="sec-link" href={localePath('/mints', locale)}>{t('home.top.all', { n: mints.length })}</a>
</div>
<div class="mint-grid">
{top.map((mint, i) => (
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
))}
</div>
</section>
<section class="section" aria-labelledby="latest-reviews-title" id="latest-reviews">
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="latest-reviews-title">{t('home.latest.title')}</h2>
<span class="sec-sub">{t('home.latest.sub')}</span>
{/*
The controls sit in the header row, never over the cards: the previous site
put arrows on top of the reviews and they covered the text. They start hidden
and the island only shows them when the track actually overflows, so with
JavaScript off, or with three reviews on a wide screen, there is nothing here
that does not work.
*/}
<div class="sec-actions">
<button class="car-btn" type="button" data-carousel-prev aria-label={t('home.latest.prev')} hidden>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button class="car-btn" type="button" data-carousel-next aria-label={t('home.latest.next')} hidden>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
<a class="sec-link" href={localePath('/reviews', locale)}>{t('home.latest.all')}</a>
</div>
</div>
{
latest.length === 0 ? (
<p class="empty">{t('home.latest.empty')}</p>
) : (
<div class="rev-track" data-carousel-track>
{latest.map((review) => {
// One identity per card: the name from kind 0, or "Anon". The npub is
// display only here, because the whole card is one link and a copy button
// inside it would be a control inside a control.
const name = profileName(review.profile);
const initial = (name?.[0] ?? review.npub[5] ?? '?').toUpperCase();
const filled = review.rating === null ? 0 : Math.round(review.rating);
return (
<a class="rev-card" href={localePath(`/mint/${review.mint_host}`, locale)} data-reveal>
<span class="rc-top">
<span class="rc-avatar-wrap">
<span class="rc-avatar" style={`background:${iconGradient(review.pubkey)}`} aria-hidden="true">
{initial}
</span>
{review.profile?.picture && (
<img
class="rc-avatar pic"
src={review.profile.picture}
alt=""
width="34"
height="34"
loading="lazy"
referrerpolicy="no-referrer"
onload="this.classList.add('loaded')"
onerror="this.remove()"
/>
)}
</span>
<span class="rc-who">
<span class="rc-identity">
{
// No NIP-05 here: at this card width it truncates to half a
// domain, which says less than nothing. It renders in full on
// the mint page, where the row has the room.
name ? (
<span class="rc-name">{name}</span>
) : (
<span class="rc-name anon">{t('reviews.anon')}</span>
)
}
</span>
<span class="rc-npub mono">{shortNpub(review.npub)}</span>
</span>
<span class:list={['rc-stars', { bad: review.rating !== null && review.rating <= 2 }]}>
<span class="sr-only">
{
review.rating === null
? t('reviews.srNoRating')
: t('reviews.srRating', { rating: String(review.rating) })
}
</span>
{/* Five elements rather than one string, so the row fills star by
star when the card arrives. */}
<span class="rc-star-row" aria-hidden="true">
{[0, 1, 2, 3, 4].map((i) => (
<span class="rc-star" style={`--star-i:${i}`}>{i < filled ? '★' : '☆'}</span>
))}
</span>
</span>
</span>
{/* The review, exactly as its author wrote it. Never translated. */}
<span class="rc-body">{review.content}</span>
<span class="rc-foot">
<span class="rc-mint">{displayDomain(review.mint_url)}</span>
<span class="rc-arrow" aria-hidden="true">→</span>
<span class="rc-date">{f.relative(review.created_at)}</span>
</span>
</a>
);
})}
</div>
)
}
</section>
<section class="section" aria-labelledby="why-trust">
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="why-trust">{t('home.why.title')}</h2>
</div>
<div class="how">
<div class="how-card" data-reveal>
<span class="how-icon" aria-hidden="true">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
</svg>
</span>
<h3>{t('home.why.signed.title')}</h3>
<p set:html={signedBody} />
</div>
<div class="how-card" data-reveal>
<span class="how-icon" aria-hidden="true">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z" />
</svg>
</span>
<h3>{t('home.why.verify.title')}</h3>
<p>{t('home.why.verify.body')}</p>
</div>
<div class="how-card" data-reveal>
<span class="how-icon" aria-hidden="true">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z" />
</svg>
</span>
<h3>{t('home.why.checked.title')}</h3>
<p>{t('home.why.checked.body')}</p>
</div>
</div>
</section>
<div class="cta">
<div class="cta-box" data-reveal>
<h2>{t('home.cta.title')}</h2>
<p>{t('home.cta.body')}</p>
<div class="cta-actions">
<a class="btn primary" href={localePath('/mints', locale)}>{t('home.cta.explore')}</a>
<a class="btn" href={localePath('/about', locale)}>{t('home.cta.learn')}</a>
</div>
</div>
</div>
</Base>
<style>
/* Positions the colour field, which is absolutely placed against this box. */
.hero-wrap { position: relative; }
/* Above the field, and its own stacking context so the search box stays clickable. */
.hero { position: relative; z-index: 1; }
.hero {
max-width: var(--page-max);
margin: 0 auto;
/* A third off the old 76/52. The pulse ticker is the proof that the site is
live, and it belongs on screen at 1280x800 without scrolling. */
padding: 136px var(--page-pad) 80px;
text-align: center;
}
.hero h1 {
font-family: var(--display);
font-weight: 700;
font-size: clamp(34px, 5vw, 56px);
letter-spacing: -.03em;
line-height: 1.06;
/* Narrow enough to break after "mint", balanced so the two lines are even:
"Find a Cashu mint" over "you can trust", never "you can" over "trust". */
max-width: 620px;
text-wrap: balance;
margin: 0 auto;
}
.hero h1 em { font-style: normal; color: var(--amber); }
.hero p { color: var(--muted); font-size: 17px; max-width: 560px; margin: 16px auto 0; text-wrap: balance; }
.search-wrap { max-width: 640px; margin: 26px auto 0; }
.search {
display: flex; align-items: center; gap: 12px;
background: var(--card); border: 1px solid var(--line);
border-radius: 14px; padding: 16px 18px;
transition: border-color .15s, box-shadow .15s;
}
.search:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
.search svg { color: var(--faint); flex-shrink: 0; }
.search input {
flex: 1; background: none; border: none; outline: none;
color: var(--text); font-family: var(--body); font-size: 16px; min-width: 0;
}
.search input::placeholder { color: var(--faint); }
.search kbd {
font-family: var(--mono); font-size: 11.5px; color: var(--faint);
border: 1px solid var(--line); border-radius: 6px; padding: 2px 7px; flex-shrink: 0;
}
.quick-filters { display: flex; gap: 8px; justify-content: center; margin-top: 14px; margin-bottom: 28px; flex-wrap: wrap; }
.qf {
font-size: 13px; font-weight: 500; color: var(--muted);
border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px;
transition: color .15s, border-color .15s, background .15s;
}
.qf:hover { color: var(--text); border-color: #38342A; background: var(--card); }
/* ---------- latest reviews carousel ---------- */
/*
* A scroll-snap track, not a JavaScript slider: native touch and trackpad swiping,
* native momentum, and it still works with the island switched off. The buttons and
* the auto-advance are additions on top of something that already scrolls.
*/
.sec-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.sec-actions .sec-link { margin-left: 4px; }
.car-btn {
width: 30px; height: 30px; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--line); border-radius: 8px;
background: var(--card); color: var(--muted); cursor: pointer;
transition: color var(--dur-quick), border-color var(--dur-quick), background var(--dur-quick);
}
.car-btn:hover { color: var(--text); border-color: #38342A; background: var(--card2); }
.car-btn:active { transform: translateY(1px); }
.car-btn[disabled] { opacity: .4; cursor: not-allowed; }
.car-btn[disabled]:hover { color: var(--muted); border-color: var(--line); background: var(--card); }
.rev-track {
display: flex;
gap: 16px;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
/* Snap points land on the container's own left edge, so a card is never half cut
at the start of the track. */
scroll-padding-left: 0;
/* Room for the card's hover lift and its focus ring, which an overflow container
would otherwise clip. */
padding: 4px 0 12px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.rev-track::-webkit-scrollbar { display: none; }
/* Soft edge while there is more to come, so the track reads as scrollable. Removed
at the end of the track, where there is nothing left to hint at. */
.rev-track.has-next {
-webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 44px), transparent 100%);
mask-image: linear-gradient(90deg, #000 calc(100% - 44px), transparent 100%);
}
.rev-card {
flex: 0 0 380px;
/* Never wider than the viewport: at 360px the card fits the column instead of
running its text under the edge. */
max-width: calc(100vw - var(--page-pad) * 2);
scroll-snap-align: start;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--card); padding: 20px; min-width: 0;
display: flex; flex-direction: column; gap: 12px;
transition: border-color var(--dur-quick), transform var(--dur-quick) var(--ease-out),
box-shadow var(--dur-quick);
}
.rev-card:hover {
border-color: #3A362B;
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, .3);
}
.rev-card:active { transform: scale(.995); }
.rc-top { display: flex; align-items: center; gap: 11px; }
.rc-avatar-wrap { position: relative; width: 34px; height: 34px; flex-shrink: 0; }
.rc-avatar {
width: 34px; height: 34px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-family: var(--display); font-weight: 600; font-size: 14px; color: #0E0D0B;
border: 1px solid var(--line); object-fit: cover;
}
/* The picture covers the generated initial, fades in when it decodes, and removes
itself if it 404s. */
.rc-avatar.pic { position: absolute; inset: 0; background: var(--card2); }
.rc-who { min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 1px; height: 34px; }
.rc-identity { display: flex; align-items: center; gap: 7px; min-width: 0; height: 19px; }
.rc-name { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-name.anon { color: var(--muted); font-weight: 500; }
/* Display only on this card: the whole card is a link, so there is no button in it. */
.rc-npub { font-size: 11px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-stars { margin-left: auto; color: var(--amber); font-size: 12.5px; flex-shrink: 0; }
.rc-stars.bad { color: var(--red); }
.rc-star-row { display: inline-flex; gap: 1.5px; }
/* The row fills star by star once the card has arrived, 40ms apart. */
.rc-star {
transition:
opacity var(--dur-quick) var(--ease-out) var(--star-in),
transform var(--dur-quick) var(--ease-out) var(--star-in);
--star-in: calc(var(--reveal-delay, 0ms) + 140ms + var(--star-i) * 40ms);
}
:global([data-reveal]:not(.in)) .rc-star { opacity: 0; transform: scale(.8); }
/* Three lines and an ellipsis, so every card in the row is the same height. */
.rc-body {
font-size: 14px; color: #D8D2C4; line-height: 1.55; flex: 1; overflow-wrap: anywhere;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3;
overflow: hidden;
}
.rc-foot {
display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--faint);
border-top: 1px solid var(--line-soft); padding-top: 12px;
}
.rc-mint {
display: block; min-width: 0; font-family: var(--mono); font-size: 12px; color: var(--muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
transition: color var(--dur-quick);
}
/* The destination, nudged: hovering the card says where it goes. */
.rc-arrow {
color: var(--amber); opacity: 0; flex-shrink: 0;
transform: translateX(-4px);
transition: opacity var(--dur-quick) var(--ease-out), transform var(--dur-quick) var(--ease-out);
}
.rev-card:hover .rc-mint, .rev-card:focus-visible .rc-mint { color: var(--amber); }
.rev-card:hover .rc-arrow, .rev-card:focus-visible .rc-arrow { opacity: 1; transform: none; }
.rc-date { margin-left: auto; flex-shrink: 0; }
.empty { color: var(--muted); font-size: 14.5px; }
.how { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.how-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); padding: 24px; }
.how-icon {
width: 40px; height: 40px; border-radius: 10px;
background: var(--card2); border: 1px solid var(--line);
display: flex; align-items: center; justify-content: center;
color: var(--amber); margin-bottom: 16px;
}
.how-card h3 { font-family: var(--display); font-size: 16.5px; font-weight: 600; margin-bottom: 8px; letter-spacing: -.01em; }
.how-card p { font-size: 13.5px; color: var(--muted); line-height: 1.6; }
.how-card p a { color: var(--violet); border-bottom: 1px dotted rgba(167, 139, 250, .4); }
.cta { max-width: var(--page-max); margin: 64px auto 0; padding: 0 var(--page-pad); }
.cta-box {
border: 1px solid var(--line); border-radius: 18px;
background: linear-gradient(135deg, var(--card) 0%, #1B1712 100%);
padding: 52px 40px; text-align: center; position: relative; overflow: hidden;
}
.cta-box::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(600px 200px at 50% 0%, rgba(240, 169, 59, .07), transparent 70%);
}
.cta-box h2 { font-family: var(--display); font-size: 30px; font-weight: 700; letter-spacing: -.02em; position: relative; }
.cta-box p { color: var(--muted); max-width: 480px; margin: 12px auto 26px; position: relative; }
.cta-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; position: relative; }
@media (max-width: 1279px) {
.how { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.how { grid-template-columns: minmax(0, 1fr); }
.hero { padding: 116px var(--page-pad) 72px; }
.section { padding: 44px var(--page-pad) 8px; }
.cta-box { padding: 38px 22px; }
.cta-box h2 { font-size: 25px; }
}
</style>
<script>
import { onLeave, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal';
/** One card every six seconds, until the reader touches the track. */
const AUTO_MS = 6000;
/** Matches the track's CSS gap. */
const GAP = 16;
const setup = (): void => {
// No track at all means the relays gave the build nothing, and the section is
// showing its empty state instead.
const found = {
track: document.querySelector<HTMLElement>('[data-carousel-track]'),
prev: document.querySelector<HTMLButtonElement>('[data-carousel-prev]'),
next: document.querySelector<HTMLButtonElement>('[data-carousel-next]'),
};
if (!found.track || !found.prev || !found.next) return;
const track: HTMLElement = found.track;
const prev: HTMLButtonElement = found.prev;
const next: HTMLButtonElement = found.next;
let timer = 0;
/**
* Auto-advance is a hint that the track scrolls, not a feature. The first time
* the reader scrolls it themselves, by button, swipe, wheel or key, it is their
* track and it never moves on its own again. With reduced motion set it never
* starts at all.
*/
let surrendered = prefersReducedMotion();
const cardStep = (): number => {
const card = track.querySelector<HTMLElement>('.rev-card');
return card ? card.getBoundingClientRect().width + GAP : track.clientWidth;
};
const overflows = (): boolean => track.scrollWidth - track.clientWidth > 4;
const atStart = (): boolean => track.scrollLeft <= 2;
const atEnd = (): boolean => track.scrollLeft >= track.scrollWidth - track.clientWidth - 2;
const stop = (): void => {
if (timer) {
window.clearInterval(timer);
timer = 0;
}
};
/** Buttons only exist when there is something to scroll to. */
function sync(): void {
const scrollable = overflows();
prev.hidden = !scrollable;
next.hidden = !scrollable;
track.classList.toggle('has-next', scrollable && !atEnd());
if (!scrollable) {
stop();
return;
}
prev.disabled = atStart();
next.disabled = atEnd();
}
const scrollByCard = (direction: number): void => {
track.scrollBy({ left: direction * cardStep(), behavior: scrollBehavior() });
};
function tick(): void {
// Paused while the pointer is over the track or focus is inside it: nothing
// should slide out from under a reader who is reading it.
if (surrendered || track.matches(':hover, :focus-within') || !overflows()) return;
if (atEnd()) track.scrollTo({ left: 0, behavior: scrollBehavior() });
else scrollByCard(1);
}
const start = (): void => {
if (surrendered || timer || !overflows()) return;
timer = window.setInterval(tick, AUTO_MS);
};
const surrender = (): void => {
surrendered = true;
stop();
};
prev.addEventListener('click', () => {
surrender();
scrollByCard(-1);
});
next.addEventListener('click', () => {
surrender();
scrollByCard(1);
});
// Deliberate interaction, as opposed to the smooth scroll this island starts
// itself. Watching these rather than the scroll event is what keeps the two
// apart without a flag and a timeout guessing at which is which.
for (const event of ['pointerdown', 'touchstart', 'keydown'] as const) {
track.addEventListener(event, surrender, { passive: true });
}
// A wheel only counts when it is aimed at the track. Scrolling the page with the
// pointer resting over the carousel is not a request to take it over.
track.addEventListener(
'wheel',
(event) => {
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) surrender();
},
{ passive: true },
);
let frame = 0;
track.addEventListener(
'scroll',
() => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
sync();
});
},
{ passive: true },
);
// A window narrow enough to make the track scrollable brings the controls, and
// the auto-advance, back with it.
const onResize = (): void => {
sync();
start();
};
window.addEventListener('resize', onResize);
sync();
start();
onLeave(() => {
stop();
window.removeEventListener('resize', onResize);
if (frame) cancelAnimationFrame(frame);
});
};
onReady(setup);
</script>