Members / index.html
wop's picture
Update index.html
bd8d4f4 verified
Raw
History Blame Contribute Delete
26.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Partners &amp; Members — Bench-labs</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;1,6..72,300;1,6..72,400&display=swap" rel="stylesheet" />
<script src="https://code.iconify.design/3/3.1.0/iconify.min.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
serif: ['Newsreader', 'serif'],
}
}
}
}
</script>
<style>
:root {
--bg-0: #05060a;
--bg-1: #0a0c14;
--ink: #e8ecf5;
--muted: #8b93a8;
--muted-2: #5c6478;
--line: rgba(255,255,255,0.08);
--line-strong: rgba(255,255,255,0.16);
--accent: #7c9cff;
--accent-soft: rgba(124,156,255,0.5);
}
* { box-sizing: border-box; }
html, body {
background: var(--bg-0);
}
body {
font-family: 'Inter', sans-serif;
color: var(--ink);
overflow-x: hidden;
}
/* ============ Space background: gradient + layered star canvas ============ */
.space-bg {
position: fixed;
inset: 0;
z-index: 0;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(90,100,180,0.18) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 85% 100%, rgba(120,80,180,0.12) 0%, transparent 60%),
linear-gradient(180deg, var(--bg-0) 0%, var(--bg-1) 100%);
pointer-events: none;
}
/* Stars are plain divs with box-shadow-free simple dots, GPU-cheap:
opacity keyframe only, no filter/blur/backdrop. */
.stars {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.star {
position: absolute;
width: 2px;
height: 2px;
border-radius: 50%;
background: #fff;
opacity: 0.5;
animation: twinkle var(--dur, 4s) ease-in-out infinite;
animation-delay: var(--delay, 0s);
}
.star.big { width: 3px; height: 3px; }
@keyframes twinkle {
0%, 100% { opacity: var(--min-op, 0.15); }
50% { opacity: var(--max-op, 0.9); }
}
@media (prefers-reduced-motion: reduce) {
.star { animation: none; opacity: 0.5; }
}
/* A few slow shooting stars, pure transform, cheap. */
.shooting-star {
position: absolute;
top: var(--top, 10%);
left: -10%;
width: 2px;
height: 2px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 1px rgba(255,255,255,0.3);
animation: shoot 6s linear infinite;
animation-delay: var(--delay, 0s);
opacity: 0;
}
.shooting-star::before {
content: '';
position: absolute;
top: 50%;
right: 0;
width: 90px;
height: 1px;
transform: translateY(-50%);
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.7));
}
@keyframes shoot {
0% { transform: translate(0, 0); opacity: 0; }
4% { opacity: 1; }
18% { opacity: 0; }
100% { transform: translate(130vw, 40px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.shooting-star { display: none; }
}
.fade { animation: fadeIn 0.6s ease both; }
.d1 { animation-delay: .05s; } .d2 { animation-delay: .12s; } .d3 { animation-delay: .18s; }
.d4 { animation-delay: .24s; } .d5 { animation-delay: .3s; } .d6 { animation-delay: .36s; } .d7 { animation-delay: .42s; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.rotating-headline {
display: inline-block;
transition: opacity 0.4s ease, transform 0.4s ease;
will-change: opacity, transform;
}
.rotating-headline.rolling-out { opacity: 0; transform: translateY(-0.5em); }
.rotating-headline.rolling-in { opacity: 0; transform: translateY(0.5em); }
@media (prefers-reduced-motion: reduce) {
.rotating-headline { transition: opacity 0.3s ease; }
.rotating-headline.rolling-out, .rotating-headline.rolling-in { transform: none; }
}
/* ============ Partners "accumulation" stage ============ */
.orbit-stage {
position: relative;
width: 100%;
max-width: 980px;
margin: 0 auto;
}
/* Each logo starts flung off-stage in a random direction, drifts/tumbles
inward, then settles permanently into its final grid slot and stops.
Pure transform + opacity keyframes (translate/rotate/scale only) so the
whole sequence is GPU-composited with no per-frame layout/paint cost,
and once the animation ends it simply holds its final state — no
ongoing free-flight, no JS animation loop. */
.orbit-item {
position: absolute;
top: 50%;
left: 50%;
width: var(--size, 160px);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin: 0 0 0 calc(var(--size, 160px) / -2);
opacity: 0;
animation: accumulate var(--dur, 1.6s) cubic-bezier(0.16, 0.9, 0.2, 1) forwards;
animation-delay: var(--delay, 0s);
will-change: transform, opacity;
/* final resting position for this item, set inline per-item */
--end-x: 0px;
--end-y: 0px;
}
@media (max-width: 640px) {
.orbit-item { --size: 118px !important; gap: 6px; }
}
.orbit-card {
width: var(--size, 160px);
height: var(--size, 160px);
border-radius: 22px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.02);
display: flex;
align-items: center;
justify-content: center;
padding: 16%;
cursor: pointer;
transition: border-color 0.25s ease, background 0.25s ease, transform 0.25s ease;
position: relative;
overflow: visible;
}
.orbit-card:hover {
border-color: var(--accent-soft);
background: rgba(124,156,255,0.06);
transform: scale(1.06);
z-index: 20;
}
.orbit-card img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
/* Caption sits permanently under the card — no hover/tap required, so it
works identically on desktop and mobile/touch. */
.orbit-caption {
text-align: center;
max-width: calc(var(--size, 160px) + 40px);
}
.orbit-caption .name {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--ink);
margin-bottom: 2px;
}
.orbit-caption .blurb {
display: block;
font-size: 10.5px;
line-height: 1.4;
color: var(--muted);
}
.orbit-caption .since {
display: block;
margin-top: 4px;
font-size: 9px;
letter-spacing: 0.04em;
color: var(--muted-2);
}
@media (max-width: 640px) {
.orbit-caption .name { font-size: 11.5px; }
.orbit-caption .blurb { font-size: 9px; }
}
@keyframes accumulate {
0% {
opacity: 0;
transform:
translate(var(--start-x, 0px), calc(var(--start-y, 0px) - var(--size, 160px) / 2))
rotate(var(--start-rot, 0deg))
scale(0.4);
}
12% {
opacity: 1;
}
70% {
opacity: 1;
transform:
translate(calc(var(--end-x, 0px) + var(--overshoot-x, 0px)), calc(var(--end-y, 0px) + var(--overshoot-y, 0px) - var(--size, 160px) / 2))
rotate(calc(var(--start-rot, 0deg) * 0.08))
scale(1.06);
}
100% {
opacity: 1;
transform: translate(var(--end-x, 0px), calc(var(--end-y, 0px) - var(--size, 160px) / 2)) rotate(0deg) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.orbit-item {
animation: none;
opacity: 1;
transform: translate(var(--end-x, 0px), calc(var(--end-y, 0px) - var(--size, 160px) / 2));
}
}
/* A soft little settle-flash on arrival, purely decorative and cheap
(opacity + scale only), so the sequential landings read clearly. */
.orbit-card::after {
content: '';
position: absolute;
inset: -10px;
border-radius: 24px;
background: radial-gradient(circle, rgba(124,156,255,0.35) 0%, transparent 70%);
opacity: 0;
animation: settleFlash 0.7s ease-out forwards;
animation-delay: inherit;
pointer-events: none;
}
@keyframes settleFlash {
0%, 60% { opacity: 0; }
72% { opacity: 1; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.orbit-card::after { display: none; }
}
/* "Partners" label sits in normal flow above the stage, so it can never
overlap the accumulating logos regardless of stage height or how many
rows the layout wraps to. */
.orbit-label {
font-size: 10px;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--muted-2);
text-align: center;
margin-bottom: 18px;
}
/* ============ Members section ============ */
.section-divider {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
max-width: 42rem;
}
.section-divider .line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--line-strong), transparent);
}
.member-card {
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255,255,255,0.02);
transition: border-color 0.25s ease, background 0.25s ease, transform 0.25s ease;
}
.member-card:hover {
border-color: var(--accent-soft);
background: rgba(124,156,255,0.05);
transform: translateY(-2px);
}
.member-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--line-strong);
background: rgba(255,255,255,0.04);
object-fit: cover;
flex-shrink: 0;
}
.member-org-badge {
font-size: 9px;
font-weight: 500;
letter-spacing: 0.03em;
color: var(--muted);
background: rgba(255,255,255,0.04);
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px 8px;
white-space: nowrap;
}
.member-hf-link {
font-size: 10px;
color: var(--accent);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 3px;
transition: color 0.15s ease;
}
.member-hf-link:hover { color: #a9beff; }
::selection { background: rgba(124,156,255,0.35); }
</style>
</head>
<body class="antialiased min-h-screen overflow-y-auto">
<div class="space-bg"></div>
<div class="stars" id="stars"></div>
<div class="relative z-10 min-h-screen flex flex-col items-center px-6 py-16">
<!-- Logo -->
<img src="benchlabs.svg" alt="Bench-labs" class="h-16 md:h-20 w-auto mb-8 fade d1"
style="filter: drop-shadow(0 0 12px rgba(124,156,255,0.35));" />
<!-- Label -->
<div class="flex items-center gap-2.5 mb-3 fade d2">
<span class="text-[11px] font-medium tracking-widest uppercase text-[var(--muted)]">Partners &amp; members</span>
</div>
<!-- Heading -->
<h1 class="font-serif italic text-3xl md:text-4xl tracking-tight text-center mb-2 fade d2">
<span id="rotating-headline" class="rotating-headline">Do you want to try something?</span>
</h1>
<p class="text-[var(--muted)] text-sm font-light text-center max-w-sm mb-6 fade d3">
Enter our joyful community and let's test together
</p>
<!-- Partners orbit -->
<div class="orbit-label fade d4">Partners</div>
<div class="orbit-stage fade d4" id="orbit-stage">
<!-- orbit items injected by JS below -->
</div>
<!-- Divider -->
<div class="section-divider mt-14 mb-10 fade d5">
<div class="line"></div>
<span class="text-[11px] font-medium tracking-widest uppercase text-[var(--muted)] whitespace-nowrap">Members</span>
<div class="line"></div>
</div>
<!-- Members grid - populated at runtime from members.json (same repo) -->
<div id="members-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-3xl fade d6">
<!-- cards injected by loadMembers() below -->
</div>
<p id="members-count" class="text-[11px] text-[var(--muted-2)] mt-6 fade d7">
Loading members&hellip;
</p>
<!-- Footer -->
<a class="mt-10 text-xs text-[var(--muted-2)] hover:text-[var(--muted)] transition-colors duration-300 fade d7">
Experimentation is for everyone
</a>
</div>
<script>
// ===================================================================
// Partner data — same content/links as before, restyled for the orbit.
// Edit this array to add/remove/update partner cards.
// ===================================================================
const PARTNERS = [
{
name: "FWKV",
url: "https://huggingface.co/FWKV",
img: "FWKV.png",
blurb: "Feed-forward Weighted Key Value",
since: "25/7/2026",
},
{
name: "FromZero",
url: "https://huggingface.co/fromziro",
img: "qITdWZiMpLE8Kop68m9OZ.webp",
blurb: "We build stuff and share it with the community.",
since: "15/7/2026",
},
{
name: "İvmeLabs",
url: "https://huggingface.co/IvmeLabs",
img: "orglogobare.png",
blurb: "Stupidly Small Models",
since: "12/7/2026",
},
{
name: "Simplicity",
url: "https://simplicity-india.com/",
img: "Simplicity.png",
blurb: "Simplicity without complexity",
since: "12/7/2026",
},
{
name: "Complexity ML",
url: "https://www.complexity-ai.fr/",
img: "Screenshot-2026-06-20-171937.png",
blurb: "Building efficient transformer architectures with Mu-Guided Dynamics and Token-Routed MLP",
since: null, // planned, not yet officially partnered
},
];
(() => {
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const isMobile =
window.matchMedia("(max-width: 767px)").matches ||
navigator.userAgentData?.mobile === true ||
/Android|iPhone|iPad|iPod|webOS|BlackBerry|Opera Mini|IEMobile/i.test(navigator.userAgent);
// ---------------- Star field ----------------
// Cheap: absolutely-positioned dots with an opacity-only twinkle. No
// blur/filter/backdrop anywhere. Count is trimmed on mobile.
const starsEl = document.getElementById("stars");
const starCount = isMobile ? 60 : 140;
const frag = document.createDocumentFragment();
for (let i = 0; i < starCount; i++) {
const s = document.createElement("div");
s.className = "star" + (Math.random() < 0.15 ? " big" : "");
s.style.left = Math.random() * 100 + "%";
s.style.top = Math.random() * 100 + "%";
s.style.setProperty("--dur", (3 + Math.random() * 5).toFixed(2) + "s");
s.style.setProperty("--delay", (-Math.random() * 6).toFixed(2) + "s");
s.style.setProperty("--min-op", (0.08 + Math.random() * 0.15).toFixed(2));
s.style.setProperty("--max-op", (0.6 + Math.random() * 0.4).toFixed(2));
frag.appendChild(s);
}
starsEl.appendChild(frag);
// A couple of slow shooting stars, skipped on mobile / reduced motion.
if (!isMobile && !prefersReducedMotion) {
const shootCount = 3;
for (let i = 0; i < shootCount; i++) {
const sh = document.createElement("div");
sh.className = "shooting-star";
sh.style.setProperty("--top", (5 + Math.random() * 40) + "%");
sh.style.setProperty("--delay", (i * 7 + Math.random() * 5).toFixed(2) + "s");
starsEl.appendChild(sh);
}
}
// ---------------- Partner accumulation sequence ----------------
// Logos start flung off in random directions, drift/tumble inward one
// after another, then settle permanently into a fixed grid layout and
// stop — no ongoing free-flight once the sequence finishes.
const stage = document.getElementById("orbit-stage");
const stageW = isMobile ? 340 : 940;
const stageH = isMobile ? 460 : 560;
const size = isMobile ? 118 : (PARTNERS.length <= 4 ? 190 : 160);
// Final resting layout: a centered row (wraps to two rows if needed),
// expressed as offsets from the stage center (where top/left:50% sits).
// Extra vertical room (captionH) is reserved per row so the always-on
// name/blurb caption under each logo never overlaps the row below it.
const perRow = isMobile ? Math.min(2, PARTNERS.length) : Math.min(4, PARTNERS.length);
const gap = isMobile ? 26 : 46;
const captionH = isMobile ? 58 : 66;
const rows = Math.ceil(PARTNERS.length / perRow);
const rowHeight = size + captionH + gap;
const stageContentHeight = rows * (size + captionH) + (rows - 1) * gap;
// extra headroom above/below so the fly-in overshoot never gets clipped
stage.style.height = (stageContentHeight + (isMobile ? 40 : 60)) + "px";
function slotPosition(index) {
const row = Math.floor(index / perRow);
const itemsInRow = Math.min(perRow, PARTNERS.length - row * perRow);
const rowWidth = itemsInRow * size + (itemsInRow - 1) * gap;
const col = index % perRow;
const x = -rowWidth / 2 + col * (size + gap) + size / 2;
const rowsTotalHeight = rows * (size + captionH) + (rows - 1) * gap;
const y = -rowsTotalHeight / 2 + row * rowHeight + size / 2;
return { x, y };
}
PARTNERS.forEach((p, i) => {
const { x: endX, y: endY } = slotPosition(i);
// Random off-stage starting point: pick an angle around the stage and
// push it out well past the visible edge so the entry reads as a
// clear "fly in from a random direction."
const angle = Math.random() * Math.PI * 2;
const throwDist = Math.max(stageW, stageH) * (0.75 + Math.random() * 0.5);
const startX = Math.cos(angle) * throwDist;
const startY = Math.sin(angle) * throwDist;
const startRot = (Math.random() < 0.5 ? -1 : 1) * (140 + Math.random() * 220);
// Small overshoot past the final slot before settling, for a soft
// "drift into place" landing rather than a hard stop.
const overshootAngle = Math.atan2(endY, endX) + (Math.random() - 0.5) * 0.6;
const overshootX = Math.cos(overshootAngle) * (10 + Math.random() * 14);
const overshootY = Math.sin(overshootAngle) * (10 + Math.random() * 14);
const duration = 1.3 + Math.random() * 0.5;
const delay = i * 0.45 + Math.random() * 0.15; // sequential accumulation, one after another
const item = document.createElement("div");
item.className = "orbit-item";
item.style.setProperty("--size", size + "px");
item.style.setProperty("--start-x", startX.toFixed(1) + "px");
item.style.setProperty("--start-y", startY.toFixed(1) + "px");
item.style.setProperty("--start-rot", startRot.toFixed(1) + "deg");
item.style.setProperty("--end-x", endX.toFixed(1) + "px");
item.style.setProperty("--end-y", endY.toFixed(1) + "px");
item.style.setProperty("--overshoot-x", overshootX.toFixed(1) + "px");
item.style.setProperty("--overshoot-y", overshootY.toFixed(1) + "px");
item.style.setProperty("--dur", duration.toFixed(2) + "s");
item.style.setProperty("--delay", delay.toFixed(2) + "s");
const card = document.createElement("div");
card.className = "orbit-card";
card.dataset.url = p.url;
const img = document.createElement("img");
img.src = p.img;
img.alt = p.name;
img.loading = "lazy";
card.appendChild(img);
card.addEventListener("click", () => {
window.open(p.url, "_blank", "noopener,noreferrer");
});
const caption = document.createElement("div");
caption.className = "orbit-caption";
caption.innerHTML =
'<span class="name">' + p.name + '</span>' +
'<span class="blurb">' + p.blurb + '</span>' +
'<span class="since">' + (p.since ? "Partnered since " + p.since : "Planned partner") + '</span>';
item.appendChild(card);
item.appendChild(caption);
stage.appendChild(item);
});
// ---------------- Adaptive performance ladder ----------------
// The accumulation animation is one-shot and short-lived, so there's
// little to cut there once it's finished; this ladder mainly protects
// the ambient star field on very low-power devices.
if (!prefersReducedMotion) {
let cuts = 0;
const steps = [
() => document.querySelectorAll(".shooting-star").forEach((s) => s.remove()),
() => {
const all = document.querySelectorAll(".star");
all.forEach((s, i) => { if (i % 2 === 0) s.remove(); });
},
];
let frames = 0, windowStart = 0, lowStreak = 0;
const THRESHOLD = 30, SAMPLE_MS = 1000, PATIENCE = 2;
function tick(now) {
frames++;
const elapsed = now - windowStart;
if (elapsed >= SAMPLE_MS) {
const fps = (frames * 1000) / elapsed;
frames = 0;
windowStart = now;
if (fps < THRESHOLD) {
if (++lowStreak >= PATIENCE && cuts < steps.length) {
lowStreak = 0;
steps[cuts]();
cuts++;
}
} else {
lowStreak = 0;
}
}
if (cuts < steps.length) requestAnimationFrame(tick);
}
setTimeout(() => {
windowStart = performance.now();
requestAnimationFrame(tick);
}, 1500);
}
})();
</script>
<script>
// ===== Members grid: fetched from members.json in this same Space repo =====
// members.json is written by the Discord bot on every /link and
// /hf-visibility change. It only ever contains members who set themselves
// to public - no Discord identity, no hidden users. See storage.py's
// sync_public_members() for how it's produced.
(async () => {
const grid = document.getElementById("members-grid");
const countEl = document.getElementById("members-count");
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str ?? "";
return div.innerHTML;
}
function initials(username) {
if (!username) return "?";
return username.slice(0, 2).toUpperCase();
}
function absoluteAvatarUrl(url) {
// Some existing records have a HF-relative path like
// "/avatars/abc.svg" instead of a full URL (from before this was
// normalized server-side in web.py) - fix it up here too so old
// data self-heals without requiring everyone to re-/link.
if (url && url.startsWith("/")) {
return `https://huggingface.co${url}`;
}
return url || "";
}
function memberCardHtml(member) {
const username = member.hf_username || "";
const profileUrl = `https://huggingface.co/${encodeURIComponent(username)}`;
const avatarUrl = absoluteAvatarUrl(member.avatar_url);
const avatar = avatarUrl
? `<img src="${escapeHtml(avatarUrl)}" alt="${escapeHtml(username)}" class="member-avatar" loading="lazy" />`
: `<div class="member-avatar flex items-center justify-center text-sm font-medium text-[var(--muted)]">${escapeHtml(initials(username))}</div>`;
const orgs = Array.isArray(member.orgs) ? member.orgs : [];
const orgBadges = orgs
.map((o) => {
const slug = o?.slug || o?.name || "";
return `<span class="member-org-badge">${escapeHtml(o?.name || slug)}</span>`;
})
.join("");
return `
<div class="member-card rounded-xl p-4 flex items-center gap-3">
${avatar}
<div class="flex flex-col gap-1 min-w-0">
<span class="text-sm font-medium text-[var(--ink)] truncate">${escapeHtml(username)}</span>
<a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="member-hf-link">
huggingface.co/${escapeHtml(username)}
<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" width="8" height="8">
<path d="M1 9L9 1M9 1H4M9 1V6"/>
</svg>
</a>
${orgBadges ? `<div class="flex flex-wrap gap-1 mt-0.5">${orgBadges}</div>` : ""}
</div>
</div>
`;
}
try {
const res = await fetch("members.json", { cache: "no-store" });
if (!res.ok) throw new Error(`members.json returned ${res.status}`);
const members = await res.json();
if (!Array.isArray(members) || members.length === 0) {
grid.innerHTML = "";
countEl.textContent = "No members yet - be the first to /link!";
return;
}
grid.innerHTML = members.map(memberCardHtml).join("");
countEl.textContent =
`${members.length} member${members.length === 1 ? "" : "s"} shown \u00b7 members who set their link to private are not listed here`;
} catch (err) {
console.error("Failed to load members.json", err);
grid.innerHTML = "";
countEl.textContent = "Couldn't load members right now - try refreshing.";
}
})();
</script>
<script>
// Rotating headline — cycles the hero question through a few variations.
(() => {
const el = document.getElementById("rotating-headline");
if (!el) return;
const phrases = [
"Do you want to try something new?",
"Do you want to build something?",
"Do you want to break something?",
"Do you want to benchmark something?",
"Got a model to upgrade?",
"Ready to experiment together?",
"Want a place to share?",
"Need help to start?",
"Made a revolutionary achivement?",
"Want to see more?",
];
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let i = 0;
setInterval(() => {
i = (i + 1) % phrases.length;
if (reduce) { el.textContent = phrases[i]; return; }
el.classList.add("rolling-out");
el.addEventListener("transitionend", function onOut() {
el.textContent = phrases[i];
el.classList.remove("rolling-out");
el.classList.add("rolling-in");
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.remove("rolling-in")));
}, { once: true });
}, 3400);
})();
</script>
</body>
</html>