browse: the roster door — mercenaries descend with bubble briefs (2.1.0)

- AgentBrowse is a doorway: a glowing open-door graphic, then mercs
  descend (staggered drop animation) hanging on wooden sign-bubbles that
  show each agent's brief; portraits carry a live/offline dot + managed crest
- ClassModal rebuilt on the shared GameDialog (extracted to GameDialog.jsx
  to avoid a Scene2D<->AgentPlatform circular import); instances render as
  a squad — living soldiers (online) vs fallen (down)
- verified locally against a seeded backend (6 mercs, squad dialog)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-11 09:14:10 +08:00
parent a350ae6884
commit 374440a392
6 changed files with 160 additions and 84 deletions

View File

@ -17,7 +17,7 @@
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } } @media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
</style> </style>
<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="2.0.0-tavern" /> <meta name="version" content="2.1.0-roster" />
<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": "2.0.0-tavern", "version": "2.1.0-roster",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api.js'; import { api } from './api.js';
import { copyToast } from './toast.js'; import { copyToast } from './toast.js';
import ChatPanel, { SessionLog } from './Chat.jsx'; import ChatPanel, { SessionLog } from './Chat.jsx';
import { GameDialog } from './GameDialog.jsx';
const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' }; const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' };
const TAG_EMOJI = { backend: '🛠️', model: '🧠', frontend: '🎨', ui: '🖌️', web: '🌐', data: '📊', devops: '⚙️', ml: '🤖', security: '🔐', api: '🔌', bot: '🤖', chat: '💬', test: '🧪', docs: '📄', design: '✏️', ops: '🚦', research: '🔬' }; const TAG_EMOJI = { backend: '🛠️', model: '🧠', frontend: '🎨', ui: '🖌️', web: '🌐', data: '📊', devops: '⚙️', ml: '🤖', security: '🔐', api: '🔌', bot: '🤖', chat: '💬', test: '🧪', docs: '📄', design: '✏️', ops: '🚦', research: '🔬' };
@ -77,14 +78,18 @@ export function AgentBrowse({ auth, setRoute }) {
}, [classes]); }, [classes]);
return ( return (
<div className="ap"> <div className="ap apb">
<div className="ap-store-head"> <div className="apb-doorway">
<div className="ap-store-intro"> <div className="apb-door" aria-hidden="true">
<h2>The mercenary roster</h2> <span className="apb-door-leaf l" /><span className="apb-door-leaf r" />
<p>Hire a ready-made agent by skill and tag chat with it, embed it on your site, or drive its terminal. Bring your own and it joins the roster.</p> <span className="apb-door-light" />
</div> </div>
<div className="apb-doorhead">
<h2> The mercenary roster</h2>
<p>Open the door and the mercenaries come down. Hire one chat, embed, or drive its terminal. {loaded ? <b>{classes.length} answering the call.</b> : 'Mustering…'}</p>
<input className="ap-search" placeholder="search the roster…" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="ap-search" placeholder="search the roster…" value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
</div>
<div className="ap-tags"> <div className="ap-tags">
<button className={`ap-tag${!tag ? ' on' : ''}`} onClick={() => setTag('')}> all</button> <button className={`ap-tag${!tag ? ' on' : ''}`} onClick={() => setTag('')}> all</button>
{Object.entries(tags).sort((a, b) => b[1] - a[1]).map(([t, n]) => ( {Object.entries(tags).sort((a, b) => b[1] - a[1]).map(([t, n]) => (
@ -92,38 +97,51 @@ export function AgentBrowse({ auth, setRoute }) {
))} ))}
</div> </div>
{err && <p className="px-auth-err">{err}</p>} {err && <p className="px-auth-err">{err}</p>}
<div className="ap-grid"> <div className="apb-stage" key={tag || 'all'}>
{!loaded && Array.from({ length: 3 }).map((_, i) => ( {!loaded && Array.from({ length: 4 }).map((_, i) => (
<div key={`sk${i}`} className="ap-card ap-skel" aria-hidden="true"> <div key={`sk${i}`} className="apb-merc apb-skel" aria-hidden="true" style={{ '--d': `${i * 80}ms` }}>
<div className="ap-skel-head"><span className="sk-av" /><span className="sk-tt"><i /><i /></span></div> <span className="apb-bub sk-p" /><span className="apb-portrait sk-av" />
<span className="sk-p" /><span className="sk-p sk-short" />
<div className="ap-skel-foot"><i /><i /></div>
</div> </div>
))} ))}
{loaded && classes.map((c) => <AgentCard key={c.id} c={c} onClick={() => openClass(c)} />)} {loaded && classes.map((c, i) => (
<button key={c.id} className="apb-merc" style={{ '--d': `${Math.min(i, 14) * 75}ms` }} onClick={() => openClass(c)}>
<span className="apb-bub">
{c.description || 'A ready blade, awaiting orders.'}
<i className="apb-bub-tail" aria-hidden="true" />
</span>
<span className="apb-rope" aria-hidden="true" />
<span className="apb-portrait" style={avatarStyle(c.name)}>
<span className="apb-emoji">{classEmoji(c)}</span>
<span className={`apb-life ${c.onlineCount ? 'on' : 'off'}`} title={c.onlineCount ? 'online' : 'offline'} />
{!!c.managed && <span className="apb-crest" title="amerc-managed">🛡</span>}
</span>
<strong className="apb-name">{c.name}</strong>
<span className="apb-meta"><span className="apb-tag-chip">{c.kind}</span> {c.onlineCount}/{c.instanceCount} online · by {c.owner}</span>
</button>
))}
{loaded && classes.length > 0 && ( {loaded && classes.length > 0 && (
<button className="ap-card ap-card-add" onClick={() => setRoute('legion')}> <button className="apb-merc apb-merc-add" style={{ '--d': `${Math.min(classes.length, 14) * 75}ms` }} onClick={() => setRoute('legion')}>
<span className="ap-add-plus" aria-hidden="true"></span> <span className="apb-bub">Bring your own blade it joins the roster the moment your broker checks in.<i className="apb-bub-tail" aria-hidden="true" /></span>
<strong>Publish your own</strong> <span className="apb-rope" aria-hidden="true" />
<p>Register an agent class and field it from your legion your mercenary joins the roster right here.</p> <span className="apb-portrait apb-portrait-add"></span>
<span className="ap-add-cta">Open My Legion </span> <strong className="apb-name">Publish your own</strong>
<span className="apb-meta">Open My Legion </span>
</button> </button>
)} )}
{loaded && !classes.length && (q || tag) && ( {loaded && !classes.length && (q || tag) && (
<div className="ap-empty-search"> <div className="ap-empty-search">
<span className="ap-empty-search-ico" aria-hidden="true">🔍</span> <span className="ap-empty-search-ico" aria-hidden="true">🔍</span>
<h3>No mercenaries match {q ? `${q}` : `#${tag}`}.</h3> <h3>No mercenaries answer {q ? `${q}` : `#${tag}`}.</h3>
<p>Try a different term or clear the filter.</p>
<button className="px-action" onClick={() => { setQ(''); setTag(''); }}>Clear search</button> <button className="px-action" onClick={() => { setQ(''); setTag(''); }}>Clear search</button>
</div> </div>
)} )}
{loaded && !classes.length && !q && !tag && ( {loaded && !classes.length && !q && !tag && (
<div className="ap-empty-start"> <div className="ap-empty-start">
<h3>No agents here yet be the first.</h3> <h3>The hall is empty be the first blade.</h3>
<ol> <ol>
<li><b>Publish a class</b> in <button className="ap-link" onClick={() => setRoute('legion')}>My Legion</button> name it, tag it (e.g. <code>backend</code>, <code>model</code>).</li> <li><b>Publish a class</b> in <button className="ap-link" onClick={() => setRoute('legion')}>My Legion</button> name it, tag it.</li>
<li><b>Run an instance</b> your broker connects and it goes <span style={{ color: STATUS_COLOR.online }}>online</span>.</li> <li><b>Field an instance</b> your broker connects and it goes <span style={{ color: STATUS_COLOR.online }}>online</span>.</li>
<li><b>Use it</b> embed it as a chatbox or drive it through a web terminal.</li> <li><b>Use it</b> chat, embed, or drive its terminal.</li>
</ol> </ol>
<button className="px-action" onClick={() => setRoute('legion')}>Open My Legion </button> <button className="px-action" onClick={() => setRoute('legion')}>Open My Legion </button>
</div> </div>
@ -146,17 +164,15 @@ function ClassModal({ id, auth, onClose }) {
if (!data) return null; if (!data) return null;
const c = data.class; const c = data.class;
return ( return (
<div className="ap-modal" onClick={onClose}> <GameDialog title={c.name} tag={`${c.kind}`} wide onClose={onClose}>
<div className="ap-modal-box" onClick={(e) => e.stopPropagation()}> <div className="cm-top">
<div className="ap-modal-head"> <span className="ap-avatar cm-avatar" style={avatarStyle(c.name)}>{classEmoji(c)}</span>
<div className="ap-modal-title"> <div className="cm-top-body">
<span className="ap-avatar" style={avatarStyle(c.name)}>{classEmoji(c)}</span> <span className="ap-by">by {c.owner}</span>
<div><h3>{c.name}</h3><span className="ap-kind">{c.kind}</span> <span className="ap-by">by {c.owner}</span></div> <p className="ap-desc">{c.description || 'No description.'}</p>
</div>
<button onClick={onClose}></button>
</div>
<p className="ap-desc">{c.description}</p>
<div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</span>)}</div> <div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</span>)}</div>
</div>
</div>
<div className="ap-props"> <div className="ap-props">
{!!c.managed && <span className="ap-prop managed">🛡 amerc-managed · amerc skills loaded</span>} {!!c.managed && <span className="ap-prop managed">🛡 amerc-managed · amerc skills loaded</span>}
<span className="ap-prop">{c.terminal_mode === 'shared' ? '🖥 one shared terminal across sessions' : '🖥 fresh terminal per session'}</span> <span className="ap-prop">{c.terminal_mode === 'shared' ? '🖥 one shared terminal across sessions' : '🖥 fresh terminal per session'}</span>
@ -167,21 +183,22 @@ function ClassModal({ id, auth, onClose }) {
{auth.user ? <button className="px-action" onClick={subscribe}>Get subscription token</button> : <span className="ap-hint">Log in to subscribe.</span>} {auth.user ? <button className="px-action" onClick={subscribe}>Get subscription token</button> : <span className="ap-hint">Log in to subscribe.</span>}
{sub && <code className="ap-token" onClick={() => copyToast(sub)}>{sub} </code>} {sub && <code className="ap-token" onClick={() => copyToast(sub)}>{sub} </code>}
</div> </div>
<h4 className="ap-h4">Instances</h4> <h4 className="ap-h4">🛡 The squad {data.instances.length} {data.instances.length === 1 ? 'soldier' : 'soldiers'}</h4>
<div className="ap-inst-list"> <div className="cm-squad">
{data.instances.map((i) => ( {data.instances.map((i) => (
<div key={i.id} className="ap-inst"> <div key={i.id} className={`cm-sol ${i.status === 'online' ? 'alive' : 'fallen'}`}>
<span className="ap-inst-status" style={{ background: STATUS_COLOR[i.status] }} title={i.status} /> <span className="cm-sol-ico" aria-hidden="true">{i.status === 'online' ? '🪖' : '💀'}</span>
<span className="ap-inst-name">#{i.id} {i.name}</span> <div className="cm-sol-body">
<span className="ap-inst-meta">{i.status}{i.broker ? ` · ${i.broker}` : ''} · {i.visibility}</span> <strong>#{i.id} {i.name}</strong>
<button className="px-action" disabled={i.status !== 'online'} onClick={() => setUse(i)}>Use </button> <span className="cm-sol-meta"><span className="cm-sol-dot" style={{ background: STATUS_COLOR[i.status] }} />{i.status}{i.broker ? ` · ${i.broker}` : ''} · {i.visibility}</span>
</div>
<button className="px-action" disabled={i.status !== 'online'} onClick={() => setUse(i)}>{i.status === 'online' ? 'Chat ▸' : 'down'}</button>
</div> </div>
))} ))}
{!data.instances.length && <p className="ap-hint">No instances published. The owner can field one in My Legion.</p>} {!data.instances.length && <p className="ap-hint">No soldiers fielded yet. The owner can field one in My Legion.</p>}
</div> </div>
{use && <UsePanel instance={use} onClose={() => setUse(null)} />} {use && <UsePanel instance={use} onClose={() => setUse(null)} />}
</div> </GameDialog>
</div>
); );
} }

27
src/GameDialog.jsx Normal file
View File

@ -0,0 +1,27 @@
import React, { useEffect } from 'react';
// Ornate, gem-cornered in-game dialog. Every "detail view" in amerc opens in
// one of these shared by the tavern, the roster, the legion and the mansion.
export function GameDialog({ title, tag, onClose, children, wide }) {
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div className="gd-backdrop" onClick={onClose}>
<div className={`gd${wide ? ' gd-wide' : ''}`} onClick={(e) => e.stopPropagation()} role="dialog" aria-label={title}>
<span className="gd-gem tl" aria-hidden="true" /><span className="gd-gem tr" aria-hidden="true" />
<span className="gd-gem bl" aria-hidden="true" /><span className="gd-gem br" aria-hidden="true" />
<header className="gd-head">
{tag && <span className="gd-tag">{tag}</span>}
<h3>{title}</h3>
<button className="gd-x" onClick={onClose} type="button" aria-label="Close"></button>
</header>
<div className="gd-body">{children}</div>
</div>
</div>
);
}
export default GameDialog;

View File

@ -4,13 +4,14 @@ import { AgentBrowse, LegionDashboard } from './AgentPlatform.jsx';
import Mansion from './Mansion.jsx'; import Mansion from './Mansion.jsx';
import Account from './Account.jsx'; import Account from './Account.jsx';
import TavernGame from './TavernGame.jsx'; import TavernGame from './TavernGame.jsx';
import { GameDialog } from './GameDialog.jsx';
import { api } from './api.js'; import { api } from './api.js';
// Inline hamburger + close icons (was lucide-react dropped for one fewer dep). // Inline hamburger + close icons (was lucide-react dropped for one fewer dep).
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>); const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>); const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
const RELEASE = '2.0.0-tavern'; const RELEASE = '2.1.0-roster';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },
@ -108,29 +109,6 @@ function Reveal({ children, className = '', delay = 0, tag: Tag = 'div' }) {
return <Tag ref={ref} className={`${className} ${armed ? 'reveal' : ''} ${shown ? 'in' : ''}`.replace(/\s+/g, ' ').trim()} style={delay && armed ? { transitionDelay: `${delay}ms` } : undefined}>{children}</Tag>; return <Tag ref={ref} className={`${className} ${armed ? 'reveal' : ''} ${shown ? 'in' : ''}`.replace(/\s+/g, ' ').trim()} style={delay && armed ? { transitionDelay: `${delay}ms` } : undefined}>{children}</Tag>;
} }
// Ornate in-game dialog every "detail view" opens in one of these.
export function GameDialog({ title, tag, onClose, children, wide }) {
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div className="gd-backdrop" onClick={onClose}>
<div className={`gd${wide ? ' gd-wide' : ''}`} onClick={(e) => e.stopPropagation()} role="dialog" aria-label={title}>
<span className="gd-gem tl" aria-hidden="true" /><span className="gd-gem tr" aria-hidden="true" />
<span className="gd-gem bl" aria-hidden="true" /><span className="gd-gem br" aria-hidden="true" />
<header className="gd-head">
{tag && <span className="gd-tag">{tag}</span>}
<h3>{title}</h3>
<button className="gd-x" onClick={onClose} type="button" aria-label="Close"></button>
</header>
<div className="gd-body">{children}</div>
</div>
</div>
);
}
function Home({ setRoute, auth }) { function Home({ setRoute, auth }) {
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [dlg, setDlg] = useState(null); // 'tour' | 'login' | null const [dlg, setDlg] = useState(null); // 'tour' | 'login' | null
@ -498,6 +476,7 @@ function StatusPage() {
} }
const CHANGELOG = [ const CHANGELOG = [
{ v: '2.1', date: 'Jun 2026', title: 'The roster comes through the door', notes: ['Browse Agents is now a doorway — the mercenaries descend and hang on wooden sign-bubbles showing what each one does', 'Open one and its detail opens in an ornate war-banner dialog; instances are shown as a squad — living soldiers when online, fallen when down'] },
{ v: '2.0', date: 'Jun 2026', title: 'The tavern becomes the interface', notes: ['The home page is now a full-screen game scene — every fixture is a door: the grand door is the roster, the bar signs you in, the banners are your Legion, the side passage leads to My Mansion', 'Speech bubbles everywhere: the dancer pitches the tour in a glowing holo-panel, the barkeep greets you by name', 'Details open in ornate in-game dialogs instead of plain pages'] }, { v: '2.0', date: 'Jun 2026', title: 'The tavern becomes the interface', notes: ['The home page is now a full-screen game scene — every fixture is a door: the grand door is the roster, the bar signs you in, the banners are your Legion, the side passage leads to My Mansion', 'Speech bubbles everywhere: the dancer pitches the tour in a glowing holo-panel, the barkeep greets you by name', 'Details open in ornate in-game dialogs instead of plain pages'] },
{ v: '1.3', date: 'Jun 2026', title: 'Mansion learns to handle files', notes: ['File-mapper: your agent maps a file to a stable link — every download streams live through your broker, nothing stored on amerc', 'Net-disk opened as a Mansion room: 100 MB per account, with a live usage meter', 'And the roster has a real agent online again — say hello to Kebab Webagent'] }, { v: '1.3', date: 'Jun 2026', title: 'Mansion learns to handle files', notes: ['File-mapper: your agent maps a file to a stable link — every download streams live through your broker, nothing stored on amerc', 'Net-disk opened as a Mansion room: 100 MB per account, with a live usage meter', 'And the roster has a real agent online again — say hello to Kebab Webagent'] },
{ v: '1.2', date: 'Jun 2026', title: 'Legion fields agent cards', notes: ['Your classes in My Legion are now the same agent cards as Browse Agents — open one to see and use its instances right there', 'New class properties at publish time: amerc-managed (loads amerc skills), terminal-per-session vs shared terminal, and skills-reload on shared terminals'] }, { v: '1.2', date: 'Jun 2026', title: 'Legion fields agent cards', notes: ['Your classes in My Legion are now the same agent cards as Browse Agents — open one to see and use its instances right there', 'New class properties at publish time: amerc-managed (loads amerc skills), terminal-per-session vs shared terminal, and skills-reload on shared terminals'] },

View File

@ -1712,3 +1712,56 @@ button {
.gm-stage3 { padding: 4px; } .gm-stage3 { padding: 4px; }
.gm-tagline { font-size: 19px; } .gm-tagline { font-size: 19px; }
} }
/* ===== Browse = the roster door; mercenaries descend (2.1) ===== */
.apb { max-width: 1080px; margin: 0 auto; }
.apb-doorway { display: flex; align-items: center; gap: 20px; padding: 14px 4px 18px; }
.apb-door { position: relative; flex: 0 0 auto; width: 96px; height: 116px; border-radius: 48px 48px 8px 8px; background: #20140a; border: 3px solid #5a3a1c; overflow: hidden; box-shadow: inset 0 0 18px rgba(0,0,0,0.7); perspective: 200px; }
.apb-door-light { position: absolute; inset: 4px; border-radius: 44px 44px 6px 6px; background: radial-gradient(ellipse at 50% 40%, rgba(127,233,255,0.85), rgba(40,120,200,0.35) 55%, transparent 75%); animation: apbGlow 3s ease-in-out infinite; }
@keyframes apbGlow { 0%,100% { opacity: 0.7; } 50% { opacity: 1; } }
.apb-door-leaf { position: absolute; top: 0; width: 50%; height: 100%; background: linear-gradient(180deg, #6b4326, #3a2414); transform-origin: var(--o) center; box-shadow: inset 0 0 8px rgba(0,0,0,0.6); }
.apb-door-leaf.l { left: 0; --o: left; transform: rotateY(58deg); border-right: 2px solid #2a1810; border-radius: 46px 0 0 6px; }
.apb-door-leaf.r { right: 0; --o: right; transform: rotateY(-58deg); border-left: 2px solid #2a1810; border-radius: 0 46px 6px 0; }
.apb-doorhead { flex: 1; min-width: 0; }
.apb-doorhead h2 { margin: 0 0 4px; font-family: Georgia, serif; font-size: 24px; color: #f3e9d2; }
.apb-doorhead p { margin: 0 0 10px; color: #9b8fb8; font-size: 13px; }
.apb-doorhead .ap-search { width: min(360px, 100%); }
.apb-stage { display: flex; flex-wrap: wrap; gap: 30px 22px; padding: 30px 6px 24px; align-items: flex-start; }
@keyframes apbDrop { from { transform: translateY(-46px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.apb-merc { position: relative; width: 156px; display: flex; flex-direction: column; align-items: center; gap: 6px; background: none; border: none; cursor: pointer; font-family: inherit; padding: 0; animation: apbDrop 0.55s cubic-bezier(0.2, 1.3, 0.4, 1) backwards; animation-delay: var(--d, 0ms); }
.apb-rope { position: absolute; top: -30px; left: 50%; width: 2px; height: 30px; background: linear-gradient(180deg, transparent, #6b5a3a); transform: translateX(-50%); }
.apb-bub { position: relative; background: linear-gradient(180deg, #241509, #170d05); border: 2px solid #8a5a32; color: #f3e3c2; border-radius: 11px; padding: 8px 11px; font-size: 11.5px; line-height: 1.4; text-align: left; min-height: 38px; display: flex; align-items: center; box-shadow: 0 3px 0 rgba(0,0,0,0.4); width: 100%; box-sizing: border-box; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
.apb-bub-tail { position: absolute; left: 50%; bottom: -7px; width: 11px; height: 11px; background: #170d05; border-right: 2px solid #8a5a32; border-bottom: 2px solid #8a5a32; transform: translateX(-50%) rotate(45deg); }
.apb-portrait { position: relative; width: 72px; height: 72px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 30px; box-shadow: 0 0 0 3px #2a2440, 0 8px 18px rgba(0,0,0,0.5); transition: transform 0.15s; }
.apb-merc:hover .apb-portrait { transform: scale(1.07) translateY(-2px); }
.apb-merc:hover .apb-bub { border-color: #ffd75e; }
.apb-emoji { filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5)); }
.apb-life { position: absolute; right: 2px; bottom: 2px; width: 16px; height: 16px; border-radius: 50%; border: 3px solid #0c0918; }
.apb-life.on { background: #36f0b0; box-shadow: 0 0 9px #36f0b0; animation: chpulse 2s infinite; }
.apb-life.off { background: #54607a; }
.apb-crest { position: absolute; left: -4px; top: -4px; font-size: 15px; filter: drop-shadow(0 0 4px rgba(54,240,176,0.6)); }
.apb-name { color: #eee6ff; font-size: 13.5px; text-align: center; }
.apb-meta { color: #8a7bb0; font-size: 10.5px; text-align: center; display: flex; align-items: center; gap: 5px; flex-wrap: wrap; justify-content: center; }
.apb-tag-chip { background: #1a2740; border: 1px solid #2a3a5a; color: #9fb2c8; border-radius: 999px; padding: 1px 7px; font-size: 9.5px; }
.apb-portrait-add { background: #141022; border: 2px dashed #4a3f6e; color: #8a7bb0; font-size: 28px; box-shadow: none; }
.apb-merc-add .apb-name { color: #b6a7d4; }
.apb-skel { pointer-events: none; }
.apb-skel .apb-bub { border-color: #243049; }
/* ClassModal as a war-banner dialog: squad of soldiers */
.cm-top { display: flex; gap: 14px; align-items: flex-start; margin-bottom: 12px; }
.cm-avatar { width: 64px; height: 64px; font-size: 30px; flex: 0 0 auto; }
.cm-top-body { min-width: 0; }
.cm-top-body .ap-desc { margin: 6px 0 8px; }
.cm-squad { display: flex; flex-direction: column; gap: 8px; }
.cm-sol { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; border: 1px solid #243049; background: #0b0f1a; }
.cm-sol.alive { border-color: #1f5642; box-shadow: inset 0 0 16px rgba(54,240,176,0.06); }
.cm-sol.fallen { opacity: 0.62; }
.cm-sol-ico { font-size: 24px; filter: grayscale(0); }
.cm-sol.fallen .cm-sol-ico { filter: grayscale(0.5); }
.cm-sol-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.cm-sol-body strong { color: #eee6ff; font-size: 13px; }
.cm-sol-meta { display: flex; align-items: center; gap: 6px; color: #8a7bb0; font-size: 11px; }
.cm-sol-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
@media (max-width: 620px) { .apb-merc { width: 132px; } .apb-doorway { flex-wrap: wrap; } }