homepage motion: scroll-reveal + count-up stats (0.45.0)

- Reveal component: feature & use cards fade-up as they enter the viewport
  (IntersectionObserver, staggered), with a :not(.in) pattern so the card
  hover-lift is never fought by the reveal transform
- Count component: hero stats ease-out count from 0 on load
- both respect prefers-reduced-motion and fall back to always-visible when
  IntersectionObserver is unavailable (no stuck-invisible content)
- verified live: all 3 cards reveal to opacity 1, stats animate
This commit is contained in:
artheru 2026-06-10 06:55:42 +08:00
parent 9e762b15ba
commit c0d67f5c3f
4 changed files with 44 additions and 10 deletions

View File

@ -4,7 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0e0a14" /> <meta name="theme-color" content="#0e0a14" />
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." /> <meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="0.44.0-roster" /> <meta name="version" content="0.45.0-motion" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" /> <link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -1,6 +1,6 @@
{ {
"name": "amerc-site", "name": "amerc-site",
"version": "0.44.0-roster", "version": "0.45.0-motion",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Menu, X } from 'lucide-react'; import { Menu, X } from 'lucide-react';
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx'; import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
import { AgentBrowse, BoothDashboard } from './AgentPlatform.jsx'; import { AgentBrowse, BoothDashboard } from './AgentPlatform.jsx';
@ -7,7 +7,7 @@ import Account from './Account.jsx';
import TavernGame from './TavernGame.jsx'; import TavernGame from './TavernGame.jsx';
import { api } from './api.js'; import { api } from './api.js';
const RELEASE = '0.44.0-roster'; const RELEASE = '0.45.0-motion';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },
@ -70,6 +70,33 @@ function PixelTopbar({ route, setRoute, auth }) {
); );
} }
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Count up from 0 to n with an ease-out curve (delightful stat reveals).
function Count({ n }) {
const [v, setV] = useState(prefersReducedMotion() ? n : 0);
useEffect(() => {
if (prefersReducedMotion()) { setV(n); return; }
let raf, start; const dur = 750;
const tick = (t) => { if (!start) start = t; const p = Math.min(1, (t - start) / dur); setV(Math.round(n * (1 - Math.pow(1 - p, 3)))); if (p < 1) raf = requestAnimationFrame(tick); };
raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf);
}, [n]);
return <>{v}</>;
}
// Fade-up as the element scrolls into view; always-visible fallback if no IO / reduced motion.
function Reveal({ children, className = '', delay = 0 }) {
const ref = useRef(null);
const [armed] = useState(() => typeof window !== 'undefined' && 'IntersectionObserver' in window && !prefersReducedMotion());
const [shown, setShown] = useState(false);
useEffect(() => {
if (!armed || !ref.current) return;
const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { setShown(true); io.disconnect(); } }); }, { threshold: 0.12, rootMargin: '0px 0px -6% 0px' });
io.observe(ref.current); return () => io.disconnect();
}, [armed]);
return <div ref={ref} className={`${className} ${armed ? 'reveal' : ''} ${shown ? 'in' : ''}`.replace(/\s+/g, ' ').trim()} style={delay && armed ? { transitionDelay: `${delay}ms` } : undefined}>{children}</div>;
}
function Home({ setRoute }) { function Home({ setRoute }) {
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); }, []); useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); }, []);
@ -82,9 +109,9 @@ function Home({ setRoute }) {
<p>Walk into the tavern talk to the <b>screen</b> to browse staff, or step to the floor to open <b>your booth</b>.</p> <p>Walk into the tavern talk to the <b>screen</b> to browse staff, or step to the floor to open <b>your booth</b>.</p>
{stats && ( {stats && (
<div className="hero-live"> <div className="hero-live">
<span><b>{stats.classes}</b> classes</span> <span><b><Count n={stats.classes} /></b> classes</span>
<span><b>{stats.online}</b> online</span> <span><b><Count n={stats.online} /></b> online</span>
<span><b>{stats.sessions}</b> sessions</span> <span><b><Count n={stats.sessions} /></b> sessions</span>
</div> </div>
)} )}
<div className="gm-scrollcue" onClick={() => document.querySelector('.hf')?.scrollIntoView({ behavior: 'smooth' })}>how it works </div> <div className="gm-scrollcue" onClick={() => document.querySelector('.hf')?.scrollIntoView({ behavior: 'smooth' })}>how it works </div>
@ -130,18 +157,18 @@ function HomeFeatures({ setRoute }) {
<p className="hf-lead">An agent mercenary layer for embedding, bringing, and hosting AI agents across vertical software boundaries.</p> <p className="hf-lead">An agent mercenary layer for embedding, bringing, and hosting AI agents across vertical software boundaries.</p>
<div className="hf-grid"> <div className="hf-grid">
{HF_STEPS.map((s, i) => ( {HF_STEPS.map((s, i) => (
<div key={i} className="hf-card"> <Reveal key={i} className="hf-card" delay={i * 90}>
<span className="hf-ico">{s.icon}</span> <span className="hf-ico">{s.icon}</span>
<h3>{s.title}</h3> <h3>{s.title}</h3>
<p>{s.body}</p> <p>{s.body}</p>
<button className="hf-link" onClick={() => setRoute(s.cta[1])}>{s.cta[0]} </button> <button className="hf-link" onClick={() => setRoute(s.cta[1])}>{s.cta[0]} </button>
</div> </Reveal>
))} ))}
</div> </div>
<h3 className="hf-sub">Three ways to use an agent</h3> <h3 className="hf-sub">Three ways to use an agent</h3>
<div className="hf-uses"> <div className="hf-uses">
{HF_USES.map((u, i) => ( {HF_USES.map((u, i) => (
<div key={i} className="hf-use"><span className="hf-ico-sm">{u.icon}</span><div><strong>{u.title}</strong><p>{u.body}</p></div></div> <Reveal key={i} className="hf-use" delay={i * 80}><span className="hf-ico-sm">{u.icon}</span><div><strong>{u.title}</strong><p>{u.body}</p></div></Reveal>
))} ))}
</div> </div>
<ActivityFeed /> <ActivityFeed />

View File

@ -1267,3 +1267,10 @@ button {
.ap-card-add strong { color: #eaf6ff; font-size: 15px; } .ap-card-add strong { color: #eaf6ff; font-size: 15px; }
.ap-card-add p { margin: 0; color: #9d8fc0; font-size: 12.5px; line-height: 1.55; } .ap-card-add p { margin: 0; color: #9d8fc0; font-size: 12.5px; line-height: 1.55; }
.ap-add-cta { color: #46c8e0; font-size: 12.5px; font-weight: 600; margin-top: 2px; } .ap-add-cta { color: #46c8e0; font-size: 12.5px; font-weight: 600; margin-top: 2px; }
/* scroll-reveal + count-up motion (0.45) */
.reveal { transition: opacity .6s cubic-bezier(.2,.7,.2,1), transform .6s cubic-bezier(.2,.7,.2,1); }
.hf-card.reveal { transition: opacity .6s cubic-bezier(.2,.7,.2,1), transform .6s cubic-bezier(.2,.7,.2,1), border-color .14s; }
.reveal:not(.in) { opacity: 0; transform: translateY(18px); }
.reveal.in { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .reveal, .reveal:not(.in) { opacity: 1 !important; transform: none !important; transition: none !important; } }