browse: loading skeletons, fix empty-state flash (0.51.0)

- AgentBrowse tracked no loaded state, so on cold load it briefly flashed
  'No agents here yet — be the first' before data arrived
- add a loaded flag: show 3 shimmer skeleton cards while fetching; gate the
  empty-state and 'publish your own' card on loaded
- skeleton shimmer respects prefers-reduced-motion
- also verified (after a stale-Chrome scare) there is NO auth bug: cookieless
  /api/auth/me returns null, fresh logged-out load shows Login (no admin nav),
  and the public-agent Use flow works without login
- verified live: skeletons during load, no flash, real card+add-card after
This commit is contained in:
artheru 2026-06-10 07:36:03 +08:00
parent c9956fbd3f
commit c9ad6407c4
5 changed files with 28 additions and 7 deletions

View File

@ -4,7 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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="version" content="0.50.0-hero" />
<meta name="version" content="0.51.0-skel" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "0.50.0-hero",
"version": "0.51.0-skel",
"private": true,
"type": "module",
"scripts": {

View File

@ -15,6 +15,7 @@ export function AgentBrowse({ auth, setRoute }) {
const [q, setQ] = useState('');
const [open, setOpen] = useState(null);
const [err, setErr] = useState('');
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => {
setErr('');
@ -22,7 +23,7 @@ export function AgentBrowse({ auth, setRoute }) {
const [c, s] = await Promise.all([api(`/agents/classes${tag ? `?tag=${encodeURIComponent(tag)}` : ''}`), api('/agents/stats')]);
setClasses((c.classes || []).filter((x) => !q || (x.name + x.tags.join() + x.description).toLowerCase().includes(q.toLowerCase())));
setTags(s.tags || {});
} catch (e) { setErr(e.message); }
} catch (e) { setErr(e.message); } finally { setLoaded(true); }
}, [tag, q]);
useEffect(() => { load(); }, [load]);
@ -43,7 +44,14 @@ export function AgentBrowse({ auth, setRoute }) {
</div>
{err && <p className="px-auth-err">{err}</p>}
<div className="ap-grid">
{classes.map((c) => (
{!loaded && Array.from({ length: 3 }).map((_, i) => (
<div key={`sk${i}`} className="ap-card ap-skel" aria-hidden="true">
<div className="ap-skel-head"><span className="sk-av" /><span className="sk-tt"><i /><i /></span></div>
<span className="sk-p" /><span className="sk-p sk-short" />
<div className="ap-skel-foot"><i /><i /></div>
</div>
))}
{loaded && classes.map((c) => (
<button key={c.id} className="ap-card" onClick={() => setOpen(c.id)}>
<div className="ap-card-head2">
<span className="ap-avatar" style={avatarStyle(c.name)}>{classEmoji(c)}</span>
@ -57,7 +65,7 @@ export function AgentBrowse({ auth, setRoute }) {
</div>
</button>
))}
{classes.length > 0 && (
{loaded && classes.length > 0 && (
<button className="ap-card ap-card-add" onClick={() => setRoute('booth')}>
<span className="ap-add-plus" aria-hidden="true"></span>
<strong>Publish your own</strong>
@ -65,7 +73,7 @@ export function AgentBrowse({ auth, setRoute }) {
<span className="ap-add-cta">Open My Booth </span>
</button>
)}
{!classes.length && (
{loaded && !classes.length && (
<div className="ap-empty-start">
<h3>No agents here yet be the first.</h3>
<ol>

View File

@ -7,7 +7,7 @@ import Account from './Account.jsx';
import TavernGame from './TavernGame.jsx';
import { api } from './api.js';
const RELEASE = '0.50.0-hero';
const RELEASE = '0.51.0-skel';
const NAV = [
{ id: 'home', label: 'Tavern' },

View File

@ -1356,3 +1356,16 @@ button {
.gm-cta-primary:hover { transform: translateY(-2px); box-shadow: 0 10px 26px rgba(243,210,122,0.42); }
.gm-cta-secondary { background: rgba(18,12,28,0.66); color: #ecdfc6; box-shadow: inset 0 0 0 1.5px rgba(243,210,122,0.5); backdrop-filter: blur(2px); }
.gm-cta-secondary:hover { background: rgba(42,30,58,0.85); color: #fff; transform: translateY(-2px); }
/* browse loading skeletons (0.51) */
.ap-skel { pointer-events: none; }
.ap-skel-head { display: flex; align-items: center; gap: 11px; }
.sk-av, .sk-tt i, .sk-p, .ap-skel-foot i { background: linear-gradient(90deg, #1a1330 25%, #261b42 37%, #1a1330 63%); background-size: 400% 100%; animation: sk-shimmer 1.4s ease infinite; border-radius: 6px; display: block; }
.sk-av { width: 38px; height: 38px; border-radius: 10px; flex: 0 0 auto; }
.sk-tt { display: flex; flex-direction: column; gap: 6px; flex: 1; }
.sk-tt i { height: 11px; width: 60%; } .sk-tt i:last-child { width: 35%; height: 9px; }
.sk-p { height: 9px; width: 100%; margin-top: 12px; } .sk-p.sk-short { width: 70%; margin-top: 7px; }
.ap-skel-foot { display: flex; justify-content: space-between; margin-top: 14px; }
.ap-skel-foot i { height: 9px; width: 30%; }
@keyframes sk-shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }
@media (prefers-reduced-motion: reduce) { .sk-av, .sk-tt i, .sk-p, .ap-skel-foot i { animation: none; } }